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};
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 =
65        load_unified_graph_for_cli(&root, &config, cli).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/// Show transitive dependencies for a module using the unified graph.
1602///
1603/// This function finds all nodes matching the module name, builds a
1604/// dependency tree, and outputs it in the requested format.
1605fn run_dependency_tree_unified(
1606    graph: &UnifiedCodeGraph,
1607    module: &str,
1608    max_depth: Option<usize>,
1609    cycles_only: bool,
1610    format: &str,
1611    verbose: bool,
1612) -> Result<()> {
1613    let snapshot = graph.snapshot();
1614
1615    // Find root nodes for this module
1616    let root_nodes = find_nodes_by_name(&snapshot, module);
1617    if root_nodes.is_empty() {
1618        bail!("Module '{module}' not found in graph");
1619    }
1620
1621    // Build dependency tree via BFS from root nodes
1622    let mut subgraph = build_dependency_tree_unified(&snapshot, &root_nodes);
1623
1624    if subgraph.nodes.is_empty() {
1625        bail!("Module '{module}' has no dependencies");
1626    }
1627
1628    // Apply max_depth filter if specified
1629    if let Some(depth_limit) = max_depth {
1630        subgraph = filter_by_depth_unified(&snapshot, &subgraph, &root_nodes, depth_limit);
1631    }
1632
1633    // If cycles_only is requested, filter to only nodes involved in cycles
1634    if cycles_only {
1635        subgraph = filter_cycles_only_unified(&subgraph);
1636        if subgraph.nodes.is_empty() {
1637            println!("No circular dependencies found for module '{module}'");
1638            return Ok(());
1639        }
1640    }
1641
1642    if verbose {
1643        eprintln!(
1644            "Dependency tree: {} nodes, {} edges",
1645            subgraph.nodes.len(),
1646            subgraph.edges.len()
1647        );
1648    }
1649
1650    // Format output
1651    match format {
1652        "json" => print_dependency_tree_unified_json(&subgraph, &snapshot, verbose),
1653        "dot" | "mermaid" | "d2" => {
1654            // Visualization formats - fall back to text with note
1655            println!("Note: Visualization format '{format}' uses text output for unified graph.");
1656            println!();
1657            print_dependency_tree_unified_text(&subgraph, &snapshot, cycles_only, verbose);
1658            Ok(())
1659        }
1660        _ => {
1661            print_dependency_tree_unified_text(&subgraph, &snapshot, cycles_only, verbose);
1662            Ok(())
1663        }
1664    }
1665}
1666
1667/// Build a dependency tree by traversing from root nodes using BFS.
1668///
1669/// Collects all transitively reachable nodes and their connecting edges.
1670fn build_dependency_tree_unified(
1671    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1672    root_nodes: &[UnifiedNodeId],
1673) -> UnifiedSubGraph {
1674    let (visited_nodes, mut edges) = collect_dependency_edges_unified(snapshot, root_nodes);
1675    let node_set: HashSet<_> = visited_nodes.iter().copied().collect();
1676    add_internal_edges_unified(snapshot, &node_set, &mut edges);
1677
1678    UnifiedSubGraph {
1679        nodes: visited_nodes.into_iter().collect(),
1680        edges,
1681    }
1682}
1683
1684/// Collect all transitive dependency nodes and edges via BFS from roots.
1685///
1686/// Uses the traversal kernel with outgoing direction and all edge types.
1687/// This replaces the previous O(E) per-node `iter_edges()` loop with the
1688/// kernel's O(degree) `edges_from()`.
1689///
1690/// # Dispatch path (DB18)
1691///
1692/// `dependency-tree` is a **NodeId-anchored multi-hop BFS** under the
1693/// Phase 3C dispatch taxonomy; it does not route through sqry-db's
1694/// name-keyed queries.
1695///
1696/// # Frontier invariant
1697///
1698/// Traversal broadens strictly through edges physically adjacent to
1699/// already-visited `NodeId`s (kernel `traverse` with `edges_from`). It
1700/// never re-resolves a name against [`find_nodes_by_name`] at depth ≥ 1,
1701/// preserving the same-name frontier invariant: a user who seeds on
1702/// `AlphaMarker::helper` cannot pull in unrelated `BetaMarker::helper`
1703/// chains. Root-node resolution happens in
1704/// [`run_dependency_tree_unified`] via [`find_nodes_by_name`] at the
1705/// handler entry; the traversal operates only on the resolved `NodeId`s
1706/// after that.
1707fn collect_dependency_edges_unified(
1708    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1709    root_nodes: &[UnifiedNodeId],
1710) -> (
1711    HashSet<UnifiedNodeId>,
1712    Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)>,
1713) {
1714    let config = TraversalConfig {
1715        direction: TraversalDirection::Outgoing,
1716        edge_filter: EdgeFilter::all(),
1717        limits: TraversalLimits {
1718            max_depth: u32::MAX,
1719            max_nodes: None,
1720            max_edges: None,
1721            max_paths: None,
1722        },
1723    };
1724
1725    let result = traverse(snapshot, root_nodes, &config, None);
1726
1727    let visited_nodes: HashSet<UnifiedNodeId> = result.nodes.iter().map(|n| n.node_id).collect();
1728
1729    let edges: Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)> = result
1730        .edges
1731        .iter()
1732        .map(|e| {
1733            (
1734                result.nodes[e.source_idx].node_id,
1735                result.nodes[e.target_idx].node_id,
1736                e.raw_kind.clone(),
1737            )
1738        })
1739        .collect();
1740
1741    (visited_nodes, edges)
1742}
1743
1744fn add_internal_edges_unified(
1745    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1746    node_set: &HashSet<UnifiedNodeId>,
1747    edges: &mut Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)>,
1748) {
1749    for (from, to, kind) in snapshot.iter_edges() {
1750        if node_set.contains(&from)
1751            && node_set.contains(&to)
1752            && !edge_exists_unified(edges, from, to)
1753        {
1754            edges.push((from, to, kind));
1755        }
1756    }
1757}
1758
1759fn edge_exists_unified(
1760    edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1761    from: UnifiedNodeId,
1762    to: UnifiedNodeId,
1763) -> bool {
1764    edges.iter().any(|&(f, t, _)| f == from && t == to)
1765}
1766
1767/// Filter subgraph by maximum depth from root nodes.
1768///
1769/// NOTE: Not migrated to kernel `traverse()` — this is a post-processing
1770/// step that filters an already-built `UnifiedSubGraph` by depth, not a
1771/// raw graph BFS. It operates on the subgraph's own adjacency list.
1772fn filter_by_depth_unified(
1773    _snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
1774    subgraph: &UnifiedSubGraph,
1775    root_nodes: &[UnifiedNodeId],
1776    max_depth: usize,
1777) -> UnifiedSubGraph {
1778    // BFS to assign depths
1779    let mut depths: HashMap<UnifiedNodeId, usize> = HashMap::new();
1780    let mut queue = VecDeque::new();
1781
1782    // Build adjacency list from edges
1783    let mut adj: HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> = HashMap::new();
1784    for &(from, to, _) in &subgraph.edges {
1785        adj.entry(from).or_default().push(to);
1786    }
1787
1788    // Initialize from root nodes
1789    let node_set: HashSet<_> = subgraph.nodes.iter().copied().collect();
1790    for &root in root_nodes {
1791        if node_set.contains(&root) {
1792            depths.insert(root, 0);
1793            queue.push_back((root, 0));
1794        }
1795    }
1796
1797    // BFS to compute depths
1798    let mut visited = HashSet::new();
1799    while let Some((current, depth)) = queue.pop_front() {
1800        if !visited.insert(current) {
1801            continue;
1802        }
1803
1804        if depth >= max_depth {
1805            continue;
1806        }
1807
1808        if let Some(neighbors) = adj.get(&current) {
1809            for &neighbor in neighbors {
1810                depths.entry(neighbor).or_insert(depth + 1);
1811                queue.push_back((neighbor, depth + 1));
1812            }
1813        }
1814    }
1815
1816    // Keep only nodes within depth limit
1817    let filtered_nodes: Vec<_> = subgraph
1818        .nodes
1819        .iter()
1820        .filter(|n| depths.get(n).is_some_and(|&d| d <= max_depth))
1821        .copied()
1822        .collect();
1823
1824    let filtered_node_set: HashSet<_> = filtered_nodes.iter().copied().collect();
1825
1826    // Keep only edges between filtered nodes
1827    let filtered_edges: Vec<_> = subgraph
1828        .edges
1829        .iter()
1830        .filter(|(from, to, _)| filtered_node_set.contains(from) && filtered_node_set.contains(to))
1831        .map(|&(from, to, ref kind)| (from, to, kind.clone()))
1832        .collect();
1833
1834    UnifiedSubGraph {
1835        nodes: filtered_nodes,
1836        edges: filtered_edges,
1837    }
1838}
1839
1840/// Filter subgraph to only include nodes involved in cycles.
1841fn filter_cycles_only_unified(subgraph: &UnifiedSubGraph) -> UnifiedSubGraph {
1842    let adj = build_adjacency_unified(&subgraph.edges);
1843    let in_cycle = collect_cycle_nodes_unified(&subgraph.nodes, &adj);
1844    let filtered_nodes: Vec<_> = subgraph
1845        .nodes
1846        .iter()
1847        .filter(|n| in_cycle.contains(n))
1848        .copied()
1849        .collect();
1850
1851    let node_set: HashSet<_> = filtered_nodes.iter().copied().collect();
1852    let filtered_edges = filter_edges_by_nodes_unified(&subgraph.edges, &node_set);
1853
1854    UnifiedSubGraph {
1855        nodes: filtered_nodes,
1856        edges: filtered_edges,
1857    }
1858}
1859
1860fn build_adjacency_unified(
1861    edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1862) -> HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> {
1863    let mut adj: HashMap<UnifiedNodeId, Vec<UnifiedNodeId>> = HashMap::new();
1864    for &(from, to, _) in edges {
1865        adj.entry(from).or_default().push(to);
1866    }
1867    adj
1868}
1869
1870fn collect_cycle_nodes_unified(
1871    nodes: &[UnifiedNodeId],
1872    adj: &HashMap<UnifiedNodeId, Vec<UnifiedNodeId>>,
1873) -> HashSet<UnifiedNodeId> {
1874    let mut in_cycle = HashSet::new();
1875    let mut visited = HashSet::new();
1876    let mut rec_stack = HashSet::new();
1877
1878    for &node in nodes {
1879        if !visited.contains(&node) {
1880            let mut path = Vec::new();
1881            dfs_cycles_unified(
1882                node,
1883                adj,
1884                &mut visited,
1885                &mut rec_stack,
1886                &mut in_cycle,
1887                &mut path,
1888            );
1889        }
1890    }
1891
1892    in_cycle
1893}
1894
1895fn dfs_cycles_unified(
1896    node: UnifiedNodeId,
1897    adj: &HashMap<UnifiedNodeId, Vec<UnifiedNodeId>>,
1898    visited: &mut HashSet<UnifiedNodeId>,
1899    rec_stack: &mut HashSet<UnifiedNodeId>,
1900    in_cycle: &mut HashSet<UnifiedNodeId>,
1901    path: &mut Vec<UnifiedNodeId>,
1902) {
1903    visited.insert(node);
1904    rec_stack.insert(node);
1905    path.push(node);
1906
1907    if let Some(neighbors) = adj.get(&node) {
1908        for &neighbor in neighbors {
1909            if !visited.contains(&neighbor) {
1910                dfs_cycles_unified(neighbor, adj, visited, rec_stack, in_cycle, path);
1911            } else if rec_stack.contains(&neighbor) {
1912                let cycle_start = path.iter().position(|&n| n == neighbor).unwrap_or(0);
1913                for &cycle_node in &path[cycle_start..] {
1914                    in_cycle.insert(cycle_node);
1915                }
1916                in_cycle.insert(neighbor);
1917            }
1918        }
1919    }
1920
1921    path.pop();
1922    rec_stack.remove(&node);
1923}
1924
1925fn filter_edges_by_nodes_unified(
1926    edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
1927    node_set: &HashSet<UnifiedNodeId>,
1928) -> Vec<(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)> {
1929    edges
1930        .iter()
1931        .filter(|(from, to, _)| node_set.contains(from) && node_set.contains(to))
1932        .map(|&(from, to, ref kind)| (from, to, kind.clone()))
1933        .collect()
1934}
1935
1936/// Print dependency tree in text format using unified graph.
1937fn print_dependency_tree_unified_text(
1938    subgraph: &UnifiedSubGraph,
1939    snapshot: &UnifiedGraphSnapshot,
1940    cycles_only: bool,
1941    verbose: bool,
1942) {
1943    let title = if cycles_only {
1944        "Dependency Tree (Cycles Only)"
1945    } else {
1946        "Dependency Tree"
1947    };
1948
1949    println!("{title}");
1950    println!("{}", "=".repeat(title.len()));
1951    println!();
1952
1953    // Print nodes
1954    let node_count = subgraph.nodes.len();
1955    println!("Nodes ({node_count}):");
1956    for &node_id in &subgraph.nodes {
1957        if let Some(entry) = snapshot.get_node(node_id) {
1958            let name = resolve_node_label(snapshot, entry);
1959            let language = resolve_node_language(snapshot, entry);
1960
1961            if verbose {
1962                let file = resolve_node_file_path(snapshot, entry, true);
1963                let line = entry.start_line;
1964                println!("  {name} ({language}) - {file}:{line}");
1965            } else {
1966                println!("  {name} ({language})");
1967            }
1968        }
1969    }
1970
1971    println!();
1972    let edge_count = subgraph.edges.len();
1973    println!("Edges ({edge_count}):");
1974    for (from_id, to_id, kind) in &subgraph.edges {
1975        let from_name =
1976            resolve_node_label_by_id(snapshot, *from_id).unwrap_or_else(|| "?".to_string());
1977        let to_name = resolve_node_label_by_id(snapshot, *to_id).unwrap_or_else(|| "?".to_string());
1978
1979        println!("  {from_name} --[{kind:?}]--> {to_name}");
1980    }
1981}
1982
1983/// Print dependency tree in JSON format using unified graph.
1984fn print_dependency_tree_unified_json(
1985    subgraph: &UnifiedSubGraph,
1986    snapshot: &UnifiedGraphSnapshot,
1987    verbose: bool,
1988) -> Result<()> {
1989    use serde_json::json;
1990
1991    let nodes: Vec<_> = subgraph
1992        .nodes
1993        .iter()
1994        .filter_map(|&node_id| {
1995            let entry = snapshot.get_node(node_id)?;
1996            let name = resolve_node_label(snapshot, entry);
1997            let language = resolve_node_language(snapshot, entry);
1998
1999            let mut obj = json!({
2000                "id": format!("{node_id:?}"),
2001                "name": name,
2002                "language": language,
2003            });
2004
2005            if verbose {
2006                let file = resolve_node_file_path(snapshot, entry, true);
2007                obj["file"] = json!(file);
2008                obj["line"] = json!(entry.start_line);
2009            }
2010
2011            Some(obj)
2012        })
2013        .collect();
2014
2015    let edges: Vec<_> = subgraph
2016        .edges
2017        .iter()
2018        .filter_map(|(from_id, to_id, kind)| {
2019            let from_name = resolve_node_label_by_id(snapshot, *from_id)?;
2020            let to_name = resolve_node_label_by_id(snapshot, *to_id)?;
2021
2022            Some(json!({
2023                "from": from_name,
2024                "to": to_name,
2025                "kind": format!("{kind:?}"),
2026            }))
2027        })
2028        .collect();
2029
2030    let output = json!({
2031        "nodes": nodes,
2032        "edges": edges,
2033        "node_count": subgraph.nodes.len(),
2034        "edge_count": subgraph.edges.len(),
2035    });
2036
2037    println!("{}", serde_json::to_string_pretty(&output)?);
2038    Ok(())
2039}
2040
2041// ===== Cross-Language Unified Implementation =====
2042
2043/// Result type for cross-language edges in unified graph
2044type UnifiedCrossLangEdge = (
2045    UnifiedNodeId,
2046    UnifiedNodeId,
2047    UnifiedEdgeKind,
2048    sqry_core::graph::Language, // from_lang
2049    sqry_core::graph::Language, // to_lang
2050);
2051
2052/// List cross-language relationships using the unified graph architecture.
2053fn run_cross_language_unified(
2054    graph: &UnifiedCodeGraph,
2055    from_lang: Option<&str>,
2056    to_lang: Option<&str>,
2057    edge_type: Option<&str>,
2058    _min_confidence: f64,
2059    format: &str,
2060    verbose: bool,
2061) -> Result<()> {
2062    let snapshot = graph.snapshot();
2063
2064    // Parse language filters
2065    let from_language = from_lang.map(parse_language).transpose()?;
2066    let to_language = to_lang.map(parse_language).transpose()?;
2067
2068    // Collect cross-language edges
2069    let mut cross_lang_edges: Vec<UnifiedCrossLangEdge> = Vec::new();
2070
2071    for (src_id, tgt_id, kind) in snapshot.iter_edges() {
2072        // Get source and target language
2073        let (src_lang, tgt_lang) = match (snapshot.get_node(src_id), snapshot.get_node(tgt_id)) {
2074            (Some(src_entry), Some(tgt_entry)) => {
2075                let src_l = snapshot.files().language_for_file(src_entry.file);
2076                let tgt_l = snapshot.files().language_for_file(tgt_entry.file);
2077                match (src_l, tgt_l) {
2078                    (Some(s), Some(t)) => (s, t),
2079                    _ => continue,
2080                }
2081            }
2082            _ => continue,
2083        };
2084
2085        // Only include cross-language edges
2086        if src_lang == tgt_lang {
2087            continue;
2088        }
2089
2090        // Apply from_lang filter
2091        if let Some(filter_lang) = from_language
2092            && src_lang != filter_lang
2093        {
2094            continue;
2095        }
2096
2097        // Apply to_lang filter
2098        if let Some(filter_lang) = to_language
2099            && tgt_lang != filter_lang
2100        {
2101            continue;
2102        }
2103
2104        // Apply edge type filter
2105        if let Some(kind_str) = edge_type
2106            && !edge_kind_matches_unified(&kind, kind_str)
2107        {
2108            continue;
2109        }
2110
2111        cross_lang_edges.push((src_id, tgt_id, kind.clone(), src_lang, tgt_lang));
2112    }
2113
2114    // min_confidence filtering is skipped here — EdgeKind doesn't carry
2115    // per-edge confidence metadata in the unified graph model.
2116
2117    // Output in requested format
2118    match format {
2119        "json" => print_cross_language_unified_json(&cross_lang_edges, &snapshot, verbose)?,
2120        _ => print_cross_language_unified_text(&cross_lang_edges, &snapshot, verbose),
2121    }
2122
2123    Ok(())
2124}
2125
2126/// Check if a unified edge kind matches a filter string.
2127fn edge_kind_matches_unified(kind: &UnifiedEdgeKind, filter: &str) -> bool {
2128    let kind_str = format!("{kind:?}").to_lowercase();
2129    let filter_lower = filter.to_lowercase();
2130    kind_str.contains(&filter_lower)
2131}
2132
2133/// Print cross-language edges in text format (unified graph).
2134fn print_cross_language_unified_text(
2135    edges: &[UnifiedCrossLangEdge],
2136    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2137    verbose: bool,
2138) {
2139    println!("Cross-Language Relationships (Unified Graph)");
2140    println!("=============================================");
2141    println!();
2142    let edge_count = edges.len();
2143    println!("Found {edge_count} cross-language edges");
2144    println!();
2145
2146    for (src_id, tgt_id, kind, src_lang, tgt_lang) in edges {
2147        let src_name = snapshot
2148            .get_node(*src_id)
2149            .and_then(|e| {
2150                e.qualified_name
2151                    .and_then(|id| snapshot.strings().resolve(id))
2152                    .or_else(|| snapshot.strings().resolve(e.name))
2153            })
2154            .map_or_else(|| "?".to_string(), |s| s.to_string());
2155
2156        let tgt_name = snapshot
2157            .get_node(*tgt_id)
2158            .and_then(|e| {
2159                e.qualified_name
2160                    .and_then(|id| snapshot.strings().resolve(id))
2161                    .or_else(|| snapshot.strings().resolve(e.name))
2162            })
2163            .map_or_else(|| "?".to_string(), |s| s.to_string());
2164
2165        println!("  {src_lang:?} → {tgt_lang:?}");
2166        println!("  {src_name} → {tgt_name}");
2167        println!("  Kind: {kind:?}");
2168
2169        if verbose
2170            && let (Some(src_entry), Some(tgt_entry)) =
2171                (snapshot.get_node(*src_id), snapshot.get_node(*tgt_id))
2172        {
2173            let src_file = snapshot.files().resolve(src_entry.file).map_or_else(
2174                || "unknown".to_string(),
2175                |p| p.to_string_lossy().to_string(),
2176            );
2177            let tgt_file = snapshot.files().resolve(tgt_entry.file).map_or_else(
2178                || "unknown".to_string(),
2179                |p| p.to_string_lossy().to_string(),
2180            );
2181            let src_line = src_entry.start_line;
2182            let tgt_line = tgt_entry.start_line;
2183            println!("  From: {src_file}:{src_line}");
2184            println!("  To:   {tgt_file}:{tgt_line}");
2185        }
2186
2187        println!();
2188    }
2189}
2190
2191/// Print cross-language edges in JSON format (unified graph).
2192fn print_cross_language_unified_json(
2193    edges: &[UnifiedCrossLangEdge],
2194    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2195    verbose: bool,
2196) -> Result<()> {
2197    use serde_json::{Value, json};
2198
2199    let items: Vec<_> = edges
2200        .iter()
2201        .filter_map(|(src_id, tgt_id, kind, src_lang, tgt_lang)| {
2202            let src_entry = snapshot.get_node(*src_id)?;
2203            let tgt_entry = snapshot.get_node(*tgt_id)?;
2204
2205            let src_name = src_entry
2206                .qualified_name
2207                .and_then(|id| snapshot.strings().resolve(id))
2208                .or_else(|| snapshot.strings().resolve(src_entry.name))
2209                .map_or_else(|| "?".to_string(), |s| s.to_string());
2210
2211            let tgt_name = tgt_entry
2212                .qualified_name
2213                .and_then(|id| snapshot.strings().resolve(id))
2214                .or_else(|| snapshot.strings().resolve(tgt_entry.name))
2215                .map_or_else(|| "?".to_string(), |s| s.to_string());
2216
2217            let mut obj = json!({
2218                "from": {
2219                    "symbol": src_name,
2220                    "language": format!("{src_lang:?}")
2221                },
2222                "to": {
2223                    "symbol": tgt_name,
2224                    "language": format!("{tgt_lang:?}")
2225                },
2226                "kind": format!("{kind:?}"),
2227            });
2228
2229            if verbose {
2230                let src_file = snapshot.files().resolve(src_entry.file).map_or_else(
2231                    || "unknown".to_string(),
2232                    |p| p.to_string_lossy().to_string(),
2233                );
2234                let tgt_file = snapshot.files().resolve(tgt_entry.file).map_or_else(
2235                    || "unknown".to_string(),
2236                    |p| p.to_string_lossy().to_string(),
2237                );
2238
2239                obj["from"]["file"] = Value::from(src_file);
2240                obj["from"]["line"] = Value::from(src_entry.start_line);
2241                obj["to"]["file"] = Value::from(tgt_file);
2242                obj["to"]["line"] = Value::from(tgt_entry.start_line);
2243            }
2244
2245            Some(obj)
2246        })
2247        .collect();
2248
2249    let output = json!({
2250        "edges": items,
2251        "count": edges.len()
2252    });
2253
2254    println!("{}", serde_json::to_string_pretty(&output)?);
2255    Ok(())
2256}
2257
2258// ===== Graph Nodes/Edges Unified Implementation =====
2259
2260const DEFAULT_GRAPH_LIST_LIMIT: usize = 1000;
2261const MAX_GRAPH_LIST_LIMIT: usize = 10_000;
2262
2263/// Pagination options for list queries.
2264struct PaginationOptions {
2265    limit: usize,
2266    offset: usize,
2267}
2268
2269/// Output formatting options.
2270struct OutputOptions<'a> {
2271    full_paths: bool,
2272    format: &'a str,
2273    verbose: bool,
2274}
2275
2276/// Filter options for node queries.
2277struct NodeFilterOptions<'a> {
2278    kind: Option<&'a str>,
2279    languages: Option<&'a str>,
2280    file: Option<&'a str>,
2281    name: Option<&'a str>,
2282    qualified_name: Option<&'a str>,
2283}
2284
2285/// Filter options for edge queries.
2286struct EdgeFilterOptions<'a> {
2287    kind: Option<&'a str>,
2288    from: Option<&'a str>,
2289    to: Option<&'a str>,
2290    from_lang: Option<&'a str>,
2291    to_lang: Option<&'a str>,
2292    file: Option<&'a str>,
2293}
2294
2295/// List unified graph nodes with filtering.
2296fn run_nodes_unified(
2297    graph: &UnifiedCodeGraph,
2298    root: &Path,
2299    filters: &NodeFilterOptions<'_>,
2300    pagination: &PaginationOptions,
2301    output: &OutputOptions<'_>,
2302) -> Result<()> {
2303    let snapshot = graph.snapshot();
2304    let kind_filter = parse_node_kind_filter(filters.kind)?;
2305    let language_filter = parse_language_filter(filters.languages)?
2306        .into_iter()
2307        .collect::<HashSet<_>>();
2308    let file_filter = filters.file.map(normalize_filter_input);
2309    let effective_limit = normalize_graph_limit(pagination.limit);
2310    let show_full_paths = output.full_paths || output.verbose;
2311
2312    let mut matches = Vec::new();
2313    for (node_id, entry) in snapshot.iter_nodes() {
2314        // Gate 0d iter-2 fix: skip unified losers from CLI
2315        // `graph nodes` listing. See `NodeEntry::is_unified_loser`.
2316        if entry.is_unified_loser() {
2317            continue;
2318        }
2319        if !kind_filter.is_empty() && !kind_filter.contains(&entry.kind) {
2320            continue;
2321        }
2322
2323        if !language_filter.is_empty() {
2324            let Some(lang) = snapshot.files().language_for_file(entry.file) else {
2325                continue;
2326            };
2327            if !language_filter.contains(&lang) {
2328                continue;
2329            }
2330        }
2331
2332        if let Some(filter) = file_filter.as_deref()
2333            && !file_filter_matches(&snapshot, entry.file, root, filter)
2334        {
2335            continue;
2336        }
2337
2338        if let Some(filter) = filters.name
2339            && !resolve_node_name(&snapshot, entry).contains(filter)
2340        {
2341            continue;
2342        }
2343
2344        if let Some(filter) = filters.qualified_name {
2345            let Some(qualified) = resolve_optional_string(&snapshot, entry.qualified_name) else {
2346                continue;
2347            };
2348            if !qualified.contains(filter) {
2349                continue;
2350            }
2351        }
2352
2353        matches.push(node_id);
2354    }
2355
2356    let total = matches.len();
2357    let start = pagination.offset.min(total);
2358    let end = (start + effective_limit).min(total);
2359    let truncated = total > start + effective_limit;
2360    let page = &matches[start..end];
2361    let page_info = ListPage::new(total, effective_limit, pagination.offset, truncated);
2362    let render_paths = RenderPaths::new(root, show_full_paths);
2363
2364    if output.format == "json" {
2365        print_nodes_unified_json(&snapshot, page, &page_info, &render_paths)
2366    } else {
2367        print_nodes_unified_text(&snapshot, page, &page_info, &render_paths, output.verbose);
2368        Ok(())
2369    }
2370}
2371
2372/// List unified graph edges with filtering.
2373fn run_edges_unified(
2374    graph: &UnifiedCodeGraph,
2375    root: &Path,
2376    filters: &EdgeFilterOptions<'_>,
2377    pagination: &PaginationOptions,
2378    output: &OutputOptions<'_>,
2379) -> Result<()> {
2380    let snapshot = graph.snapshot();
2381    let kind_filter = parse_edge_kind_filter(filters.kind)?;
2382    let from_language = filters.from_lang.map(parse_language).transpose()?;
2383    let to_language = filters.to_lang.map(parse_language).transpose()?;
2384    let file_filter = filters.file.map(normalize_filter_input);
2385    let effective_limit = normalize_graph_limit(pagination.limit);
2386    let show_full_paths = output.full_paths || output.verbose;
2387
2388    let mut matches = Vec::new();
2389    for (src_id, tgt_id, kind) in snapshot.iter_edges() {
2390        if !kind_filter.is_empty() && !kind_filter.contains(kind.tag()) {
2391            continue;
2392        }
2393
2394        let (Some(src_entry), Some(tgt_entry)) =
2395            (snapshot.get_node(src_id), snapshot.get_node(tgt_id))
2396        else {
2397            continue;
2398        };
2399
2400        if let Some(filter_lang) = from_language {
2401            let Some(lang) = snapshot.files().language_for_file(src_entry.file) else {
2402                continue;
2403            };
2404            if lang != filter_lang {
2405                continue;
2406            }
2407        }
2408
2409        if let Some(filter_lang) = to_language {
2410            let Some(lang) = snapshot.files().language_for_file(tgt_entry.file) else {
2411                continue;
2412            };
2413            if lang != filter_lang {
2414                continue;
2415            }
2416        }
2417
2418        if let Some(filter) = filters.from
2419            && !node_label_matches(&snapshot, src_entry, filter)
2420        {
2421            continue;
2422        }
2423
2424        if let Some(filter) = filters.to
2425            && !node_label_matches(&snapshot, tgt_entry, filter)
2426        {
2427            continue;
2428        }
2429
2430        if let Some(filter) = file_filter.as_deref()
2431            && !file_filter_matches(&snapshot, src_entry.file, root, filter)
2432        {
2433            continue;
2434        }
2435
2436        matches.push((src_id, tgt_id, kind));
2437    }
2438
2439    let total = matches.len();
2440    let start = pagination.offset.min(total);
2441    let end = (start + effective_limit).min(total);
2442    let truncated = total > start + effective_limit;
2443    let page = &matches[start..end];
2444    let page_info = ListPage::new(total, effective_limit, pagination.offset, truncated);
2445    let render_paths = RenderPaths::new(root, show_full_paths);
2446
2447    if output.format == "json" {
2448        print_edges_unified_json(&snapshot, page, &page_info, &render_paths)
2449    } else {
2450        print_edges_unified_text(&snapshot, page, &page_info, &render_paths, output.verbose);
2451        Ok(())
2452    }
2453}
2454
2455fn print_nodes_unified_text(
2456    snapshot: &UnifiedGraphSnapshot,
2457    nodes: &[UnifiedNodeId],
2458    page: &ListPage,
2459    paths: &RenderPaths<'_>,
2460    verbose: bool,
2461) {
2462    println!("Graph Nodes (Unified Graph)");
2463    println!("===========================");
2464    println!();
2465    let shown = nodes.len();
2466    println!(
2467        "Found {total} node(s). Showing {shown} (offset {offset}, limit {limit}).",
2468        total = page.total,
2469        offset = page.offset,
2470        limit = page.limit
2471    );
2472    if page.truncated {
2473        println!("Results truncated. Use --limit/--offset to page.");
2474    }
2475    println!();
2476
2477    for (index, node_id) in nodes.iter().enumerate() {
2478        let Some(entry) = snapshot.get_node(*node_id) else {
2479            continue;
2480        };
2481        let display_index = page.offset + index + 1;
2482        let name = resolve_node_name(snapshot, entry);
2483        let qualified = resolve_optional_string(snapshot, entry.qualified_name);
2484        let language = resolve_node_language_text(snapshot, entry);
2485        let kind = entry.kind.as_str();
2486        let file = render_file_path(snapshot, entry.file, paths.root, paths.full_paths);
2487
2488        println!("{display_index}. {name} ({kind}, {language})");
2489        println!(
2490            "   File: {file}:{}:{}",
2491            entry.start_line, entry.start_column
2492        );
2493        if let Some(qualified) = qualified.as_ref()
2494            && qualified != &name
2495        {
2496            println!("   Qualified: {qualified}");
2497        }
2498
2499        if verbose {
2500            println!("   Id: {}", format_node_id(*node_id));
2501            if let Some(signature) = resolve_optional_string(snapshot, entry.signature) {
2502                println!("   Signature: {signature}");
2503            }
2504            if let Some(visibility) = resolve_optional_string(snapshot, entry.visibility) {
2505                println!("   Visibility: {visibility}");
2506            }
2507            println!(
2508                "   Location: {}:{}-{}:{}",
2509                entry.start_line, entry.start_column, entry.end_line, entry.end_column
2510            );
2511            println!("   Byte range: {}-{}", entry.start_byte, entry.end_byte);
2512            println!(
2513                "   Flags: async={}, static={}",
2514                entry.is_async, entry.is_static
2515            );
2516            if let Some(doc) = resolve_optional_string(snapshot, entry.doc) {
2517                let condensed = condense_whitespace(&doc);
2518                println!("   Doc: {condensed}");
2519            }
2520        }
2521
2522        println!();
2523    }
2524}
2525
2526fn print_nodes_unified_json(
2527    snapshot: &UnifiedGraphSnapshot,
2528    nodes: &[UnifiedNodeId],
2529    page: &ListPage,
2530    paths: &RenderPaths<'_>,
2531) -> Result<()> {
2532    use serde_json::json;
2533
2534    let items: Vec<_> = nodes
2535        .iter()
2536        .filter_map(|node_id| {
2537            let entry = snapshot.get_node(*node_id)?;
2538            let name = resolve_node_name(snapshot, entry);
2539            let qualified = resolve_optional_string(snapshot, entry.qualified_name);
2540            let language = resolve_node_language_json(snapshot, entry);
2541            let file = render_file_path(snapshot, entry.file, paths.root, paths.full_paths);
2542            let signature = resolve_optional_string(snapshot, entry.signature);
2543            let doc = resolve_optional_string(snapshot, entry.doc);
2544            let visibility = resolve_optional_string(snapshot, entry.visibility);
2545
2546            Some(json!({
2547                "id": node_id_json(*node_id),
2548                "name": name,
2549                "qualified_name": qualified,
2550                "kind": entry.kind.as_str(),
2551                "language": language,
2552                "file": file,
2553                "location": {
2554                    "start_line": entry.start_line,
2555                    "start_column": entry.start_column,
2556                    "end_line": entry.end_line,
2557                    "end_column": entry.end_column,
2558                },
2559                "byte_range": {
2560                    "start": entry.start_byte,
2561                    "end": entry.end_byte,
2562                },
2563                "signature": signature,
2564                "doc": doc,
2565                "visibility": visibility,
2566                "is_async": entry.is_async,
2567                "is_static": entry.is_static,
2568            }))
2569        })
2570        .collect();
2571
2572    let output = json!({
2573        "count": page.total,
2574        "limit": page.limit,
2575        "offset": page.offset,
2576        "truncated": page.truncated,
2577        "nodes": items,
2578    });
2579
2580    println!("{}", serde_json::to_string_pretty(&output)?);
2581    Ok(())
2582}
2583
2584fn print_edges_unified_text(
2585    snapshot: &UnifiedGraphSnapshot,
2586    edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
2587    page: &ListPage,
2588    paths: &RenderPaths<'_>,
2589    verbose: bool,
2590) {
2591    println!("Graph Edges (Unified Graph)");
2592    println!("===========================");
2593    println!();
2594    let shown = edges.len();
2595    println!(
2596        "Found {total} edge(s). Showing {shown} (offset {offset}, limit {limit}).",
2597        total = page.total,
2598        offset = page.offset,
2599        limit = page.limit
2600    );
2601    if page.truncated {
2602        println!("Results truncated. Use --limit/--offset to page.");
2603    }
2604    println!();
2605
2606    for (index, (src_id, tgt_id, kind)) in edges.iter().enumerate() {
2607        let (Some(src_entry), Some(tgt_entry)) =
2608            (snapshot.get_node(*src_id), snapshot.get_node(*tgt_id))
2609        else {
2610            continue;
2611        };
2612        let display_index = page.offset + index + 1;
2613        let src_name = resolve_node_label(snapshot, src_entry);
2614        let tgt_name = resolve_node_label(snapshot, tgt_entry);
2615        let src_lang = resolve_node_language_text(snapshot, src_entry);
2616        let tgt_lang = resolve_node_language_text(snapshot, tgt_entry);
2617        let file = render_file_path(snapshot, src_entry.file, paths.root, paths.full_paths);
2618
2619        println!("{display_index}. {src_name} ({src_lang}) → {tgt_name} ({tgt_lang})");
2620        println!("   Kind: {}", kind.tag());
2621        println!("   File: {file}");
2622
2623        if verbose {
2624            println!(
2625                "   Source: {}:{}:{}",
2626                file, src_entry.start_line, src_entry.start_column
2627            );
2628            let target_file =
2629                render_file_path(snapshot, tgt_entry.file, paths.root, paths.full_paths);
2630            println!(
2631                "   Target: {}:{}:{}",
2632                target_file, tgt_entry.start_line, tgt_entry.start_column
2633            );
2634            println!("   Source Id: {}", format_node_id(*src_id));
2635            println!("   Target Id: {}", format_node_id(*tgt_id));
2636            print_edge_metadata_text(snapshot, kind);
2637        }
2638
2639        println!();
2640    }
2641}
2642
2643fn print_edges_unified_json(
2644    snapshot: &UnifiedGraphSnapshot,
2645    edges: &[(UnifiedNodeId, UnifiedNodeId, UnifiedEdgeKind)],
2646    page: &ListPage,
2647    paths: &RenderPaths<'_>,
2648) -> Result<()> {
2649    use serde_json::json;
2650
2651    let items: Vec<_> = edges
2652        .iter()
2653        .filter_map(|(src_id, tgt_id, kind)| {
2654            let src_entry = snapshot.get_node(*src_id)?;
2655            let tgt_entry = snapshot.get_node(*tgt_id)?;
2656            let file = render_file_path(snapshot, src_entry.file, paths.root, paths.full_paths);
2657
2658            Some(json!({
2659                "source": node_ref_json(snapshot, *src_id, src_entry, paths.root, paths.full_paths),
2660                "target": node_ref_json(snapshot, *tgt_id, tgt_entry, paths.root, paths.full_paths),
2661                "kind": kind.tag(),
2662                "file": file,
2663                "metadata": edge_metadata_json(snapshot, kind),
2664            }))
2665        })
2666        .collect();
2667
2668    let output = json!({
2669        "count": page.total,
2670        "limit": page.limit,
2671        "offset": page.offset,
2672        "truncated": page.truncated,
2673        "edges": items,
2674    });
2675
2676    println!("{}", serde_json::to_string_pretty(&output)?);
2677    Ok(())
2678}
2679
2680// ===== Complexity Unified Implementation =====
2681
2682/// Result type for complexity metrics in unified graph
2683type UnifiedComplexityResult = (UnifiedNodeId, usize);
2684
2685/// Calculate and display complexity metrics using the unified graph architecture.
2686fn run_complexity_unified(
2687    graph: &UnifiedCodeGraph,
2688    target: Option<&str>,
2689    sort: bool,
2690    min_complexity: usize,
2691    languages: Option<&str>,
2692    format: &str,
2693    verbose: bool,
2694) -> Result<()> {
2695    let snapshot = graph.snapshot();
2696
2697    // Parse language filter
2698    let language_list = parse_language_filter_for_complexity(languages)?;
2699    let language_filter: HashSet<_> = language_list.into_iter().collect();
2700
2701    // Calculate complexity for all functions
2702    let mut complexities =
2703        calculate_complexity_metrics_unified(&snapshot, target, &language_filter);
2704
2705    // Filter by minimum complexity
2706    complexities.retain(|(_, score)| *score >= min_complexity);
2707
2708    // Sort if requested
2709    if sort {
2710        complexities.sort_by(|a, b| b.1.cmp(&a.1));
2711    }
2712
2713    if verbose {
2714        eprintln!(
2715            "Analyzed {} functions (min_complexity={})",
2716            complexities.len(),
2717            min_complexity
2718        );
2719    }
2720
2721    match format {
2722        "json" => print_complexity_unified_json(&complexities, &snapshot)?,
2723        _ => print_complexity_unified_text(&complexities, &snapshot),
2724    }
2725
2726    Ok(())
2727}
2728
2729/// Parse language filter for complexity command.
2730fn parse_language_filter_for_complexity(languages: Option<&str>) -> Result<Vec<Language>> {
2731    if let Some(langs) = languages {
2732        langs.split(',').map(|s| parse_language(s.trim())).collect()
2733    } else {
2734        Ok(Vec::new())
2735    }
2736}
2737
2738/// Calculate complexity metrics for all functions in the unified graph.
2739fn calculate_complexity_metrics_unified(
2740    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2741    target: Option<&str>,
2742    language_filter: &HashSet<Language>,
2743) -> Vec<UnifiedComplexityResult> {
2744    use sqry_core::graph::unified::node::NodeKind as UnifiedNodeKind;
2745
2746    let mut complexities = Vec::new();
2747
2748    for (node_id, entry) in snapshot.iter_nodes() {
2749        // Gate 0d iter-2 fix: skip unified losers from CLI
2750        // complexity metrics. See `NodeEntry::is_unified_loser`.
2751        if entry.is_unified_loser() {
2752            continue;
2753        }
2754        if !node_matches_language_filter(snapshot, entry, language_filter) {
2755            continue;
2756        }
2757
2758        if !matches!(
2759            entry.kind,
2760            UnifiedNodeKind::Function | UnifiedNodeKind::Method
2761        ) {
2762            continue;
2763        }
2764
2765        if !node_matches_target(snapshot, entry, target) {
2766            continue;
2767        }
2768
2769        // Calculate complexity score
2770        let score = calculate_complexity_score_unified(snapshot, node_id);
2771        complexities.push((node_id, score));
2772    }
2773
2774    complexities
2775}
2776
2777fn node_matches_language_filter(
2778    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2779    entry: &NodeEntry,
2780    language_filter: &HashSet<Language>,
2781) -> bool {
2782    if language_filter.is_empty() {
2783        return true;
2784    }
2785
2786    let Some(lang) = snapshot.files().language_for_file(entry.file) else {
2787        return false;
2788    };
2789    language_filter.contains(&lang)
2790}
2791
2792fn node_matches_target(
2793    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2794    entry: &NodeEntry,
2795    target: Option<&str>,
2796) -> bool {
2797    let Some(target_name) = target else {
2798        return true;
2799    };
2800
2801    let name = entry
2802        .qualified_name
2803        .and_then(|id| snapshot.strings().resolve(id))
2804        .or_else(|| snapshot.strings().resolve(entry.name))
2805        .map_or_else(String::new, |s| s.to_string());
2806
2807    name.contains(target_name)
2808}
2809
2810/// Calculate complexity score for a single function in the unified graph.
2811fn calculate_complexity_score_unified(
2812    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2813    node_id: UnifiedNodeId,
2814) -> usize {
2815    use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKindEnum;
2816
2817    // Simple complexity metric: count of outgoing call edges + call chain depth
2818    let mut call_count = 0;
2819    let mut max_depth = 0;
2820
2821    // Count direct calls by iterating over all outgoing edges
2822    for edge_ref in snapshot.edges().edges_from(node_id) {
2823        if matches!(edge_ref.kind, UnifiedEdgeKindEnum::Calls { .. }) {
2824            call_count += 1;
2825
2826            // Calculate depth to this callee
2827            let depth = calculate_call_depth_unified(snapshot, edge_ref.target, 1);
2828            max_depth = max_depth.max(depth);
2829        }
2830    }
2831
2832    // Complexity = direct calls + max chain depth
2833    call_count + max_depth
2834}
2835
2836/// Calculate call depth from a node in the unified graph.
2837fn calculate_call_depth_unified(
2838    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2839    node_id: UnifiedNodeId,
2840    current_depth: usize,
2841) -> usize {
2842    use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKindEnum;
2843
2844    const MAX_DEPTH: usize = 20; // Prevent infinite recursion
2845
2846    if current_depth >= MAX_DEPTH {
2847        return current_depth;
2848    }
2849
2850    let mut max_child_depth = current_depth;
2851
2852    for edge_ref in snapshot.edges().edges_from(node_id) {
2853        if matches!(edge_ref.kind, UnifiedEdgeKindEnum::Calls { .. }) {
2854            let child_depth =
2855                calculate_call_depth_unified(snapshot, edge_ref.target, current_depth + 1);
2856            max_child_depth = max_child_depth.max(child_depth);
2857        }
2858    }
2859
2860    max_child_depth
2861}
2862
2863/// Print complexity metrics in text format (unified graph).
2864fn print_complexity_unified_text(
2865    complexities: &[UnifiedComplexityResult],
2866    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2867) {
2868    println!("Code Complexity Metrics (Unified Graph)");
2869    println!("=======================================");
2870    println!();
2871    let complexity_count = complexities.len();
2872    println!("Analyzed {complexity_count} functions");
2873    println!();
2874
2875    if complexities.is_empty() {
2876        println!("No functions found matching the criteria.");
2877        return;
2878    }
2879
2880    // Calculate statistics
2881    let scores: Vec<_> = complexities.iter().map(|(_, score)| *score).collect();
2882    let total: usize = scores.iter().sum();
2883    #[allow(clippy::cast_precision_loss)] // Display-only metric; precision is non-critical.
2884    let avg = total as f64 / scores.len() as f64;
2885    let max = *scores.iter().max().unwrap_or(&0);
2886
2887    println!("Statistics:");
2888    println!("  Average complexity: {avg:.1}");
2889    println!("  Maximum complexity: {max}");
2890    println!();
2891
2892    println!("Functions by complexity:");
2893    for (node_id, score) in complexities {
2894        let bars = "█".repeat((*score).min(50));
2895
2896        let (name, file, lang_str) = if let Some(entry) = snapshot.get_node(*node_id) {
2897            let n = entry
2898                .qualified_name
2899                .and_then(|id| snapshot.strings().resolve(id))
2900                .or_else(|| snapshot.strings().resolve(entry.name))
2901                .map_or_else(|| "?".to_string(), |s| s.to_string());
2902
2903            let f = snapshot.files().resolve(entry.file).map_or_else(
2904                || "unknown".to_string(),
2905                |p| p.to_string_lossy().to_string(),
2906            );
2907
2908            let l = snapshot
2909                .files()
2910                .language_for_file(entry.file)
2911                .map_or_else(|| "Unknown".to_string(), |lang| format!("{lang:?}"));
2912
2913            (n, f, l)
2914        } else {
2915            (
2916                "?".to_string(),
2917                "unknown".to_string(),
2918                "Unknown".to_string(),
2919            )
2920        };
2921
2922        println!("  {bars} {score:3} {lang_str}:{file}:{name}");
2923    }
2924}
2925
2926/// Print complexity metrics in JSON format (unified graph).
2927fn print_complexity_unified_json(
2928    complexities: &[UnifiedComplexityResult],
2929    snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
2930) -> Result<()> {
2931    use serde_json::json;
2932
2933    let items: Vec<_> = complexities
2934        .iter()
2935        .filter_map(|(node_id, score)| {
2936            let entry = snapshot.get_node(*node_id)?;
2937
2938            let name = entry
2939                .qualified_name
2940                .and_then(|id| snapshot.strings().resolve(id))
2941                .or_else(|| snapshot.strings().resolve(entry.name))
2942                .map_or_else(|| "?".to_string(), |s| s.to_string());
2943
2944            let file = snapshot.files().resolve(entry.file).map_or_else(
2945                || "unknown".to_string(),
2946                |p| p.to_string_lossy().to_string(),
2947            );
2948
2949            let language = snapshot
2950                .files()
2951                .language_for_file(entry.file)
2952                .map_or_else(|| "Unknown".to_string(), |l| format!("{l:?}"));
2953
2954            Some(json!({
2955                "symbol": name,
2956                "file": file,
2957                "language": language,
2958                "complexity": score,
2959            }))
2960        })
2961        .collect();
2962
2963    let output = json!({
2964        "function_count": complexities.len(),
2965        "functions": items,
2966    });
2967
2968    println!("{}", serde_json::to_string_pretty(&output)?);
2969    Ok(())
2970}
2971
2972// ===== Helper Functions =====
2973
2974const VALID_NODE_KIND_NAMES: &[&str] = &[
2975    "function",
2976    "method",
2977    "class",
2978    "interface",
2979    "trait",
2980    "module",
2981    "variable",
2982    "constant",
2983    "type",
2984    "struct",
2985    "enum",
2986    "enum_variant",
2987    "macro",
2988    "call_site",
2989    "import",
2990    "export",
2991    "lifetime",
2992    "component",
2993    "service",
2994    "resource",
2995    "endpoint",
2996    "test",
2997    "other",
2998];
2999
3000const VALID_EDGE_KIND_TAGS: &[&str] = &[
3001    "defines",
3002    "contains",
3003    "calls",
3004    "references",
3005    "imports",
3006    "exports",
3007    "type_of",
3008    "inherits",
3009    "implements",
3010    "lifetime_constraint",
3011    "trait_method_binding",
3012    "macro_expansion",
3013    "ffi_call",
3014    "http_request",
3015    "grpc_call",
3016    "web_assembly_call",
3017    "db_query",
3018    "table_read",
3019    "table_write",
3020    "triggered_by",
3021    "message_queue",
3022    "web_socket",
3023    "graphql_operation",
3024    "process_exec",
3025    "file_ipc",
3026    "protocol_call",
3027];
3028
3029struct ListPage {
3030    total: usize,
3031    limit: usize,
3032    offset: usize,
3033    truncated: bool,
3034}
3035
3036impl ListPage {
3037    fn new(total: usize, limit: usize, offset: usize, truncated: bool) -> Self {
3038        Self {
3039            total,
3040            limit,
3041            offset,
3042            truncated,
3043        }
3044    }
3045}
3046
3047struct RenderPaths<'a> {
3048    root: &'a Path,
3049    full_paths: bool,
3050}
3051
3052impl<'a> RenderPaths<'a> {
3053    fn new(root: &'a Path, full_paths: bool) -> Self {
3054        Self { root, full_paths }
3055    }
3056}
3057
3058fn normalize_graph_limit(limit: usize) -> usize {
3059    if limit == 0 {
3060        DEFAULT_GRAPH_LIST_LIMIT
3061    } else {
3062        limit.min(MAX_GRAPH_LIST_LIMIT)
3063    }
3064}
3065
3066fn normalize_filter_input(input: &str) -> String {
3067    input.trim().replace('\\', "/").to_ascii_lowercase()
3068}
3069
3070fn normalize_path_for_match(path: &Path) -> String {
3071    path.to_string_lossy()
3072        .replace('\\', "/")
3073        .to_ascii_lowercase()
3074}
3075
3076fn file_filter_matches(
3077    snapshot: &UnifiedGraphSnapshot,
3078    file_id: sqry_core::graph::unified::FileId,
3079    root: &Path,
3080    filter: &str,
3081) -> bool {
3082    let Some(path) = snapshot.files().resolve(file_id) else {
3083        return false;
3084    };
3085    let normalized = normalize_path_for_match(&path);
3086    if normalized.contains(filter) {
3087        return true;
3088    }
3089
3090    if let Ok(relative) = path.strip_prefix(root) {
3091        let normalized_relative = normalize_path_for_match(relative);
3092        if normalized_relative.contains(filter) {
3093            return true;
3094        }
3095    }
3096
3097    false
3098}
3099
3100fn render_file_path(
3101    snapshot: &UnifiedGraphSnapshot,
3102    file_id: sqry_core::graph::unified::FileId,
3103    root: &Path,
3104    full_paths: bool,
3105) -> String {
3106    snapshot.files().resolve(file_id).map_or_else(
3107        || "unknown".to_string(),
3108        |path| {
3109            if full_paths {
3110                path.to_string_lossy().to_string()
3111            } else if let Ok(relative) = path.strip_prefix(root) {
3112                relative.to_string_lossy().to_string()
3113            } else {
3114                path.to_string_lossy().to_string()
3115            }
3116        },
3117    )
3118}
3119
3120fn resolve_optional_string(
3121    snapshot: &UnifiedGraphSnapshot,
3122    value: Option<StringId>,
3123) -> Option<String> {
3124    value
3125        .and_then(|id| snapshot.strings().resolve(id))
3126        .map(|s| s.to_string())
3127}
3128
3129fn resolve_node_language_text(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
3130    snapshot
3131        .files()
3132        .language_for_file(entry.file)
3133        .map_or_else(|| "Unknown".to_string(), |lang| format!("{lang:?}"))
3134}
3135
3136fn resolve_node_language_json(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry) -> String {
3137    snapshot
3138        .files()
3139        .language_for_file(entry.file)
3140        .map_or_else(|| "unknown".to_string(), |lang| lang.to_string())
3141}
3142
3143fn node_label_matches(snapshot: &UnifiedGraphSnapshot, entry: &NodeEntry, filter: &str) -> bool {
3144    let name = resolve_node_name(snapshot, entry);
3145    if name.contains(filter) {
3146        return true;
3147    }
3148
3149    if let Some(qualified) = resolve_optional_string(snapshot, entry.qualified_name)
3150        && qualified.contains(filter)
3151    {
3152        return true;
3153    }
3154
3155    false
3156}
3157
3158fn condense_whitespace(value: &str) -> String {
3159    value.split_whitespace().collect::<Vec<_>>().join(" ")
3160}
3161
3162fn format_node_id(node_id: UnifiedNodeId) -> String {
3163    format!(
3164        "index={}, generation={}",
3165        node_id.index(),
3166        node_id.generation()
3167    )
3168}
3169
3170fn node_id_json(node_id: UnifiedNodeId) -> serde_json::Value {
3171    use serde_json::json;
3172
3173    json!({
3174        "index": node_id.index(),
3175        "generation": node_id.generation(),
3176    })
3177}
3178
3179fn node_ref_json(
3180    snapshot: &UnifiedGraphSnapshot,
3181    node_id: UnifiedNodeId,
3182    entry: &NodeEntry,
3183    root: &Path,
3184    full_paths: bool,
3185) -> serde_json::Value {
3186    use serde_json::json;
3187
3188    let name = resolve_node_name(snapshot, entry);
3189    let qualified = resolve_optional_string(snapshot, entry.qualified_name);
3190    let language = resolve_node_language_json(snapshot, entry);
3191    let file = render_file_path(snapshot, entry.file, root, full_paths);
3192
3193    json!({
3194        "id": node_id_json(node_id),
3195        "name": name,
3196        "qualified_name": qualified,
3197        "language": language,
3198        "file": file,
3199        "location": {
3200            "start_line": entry.start_line,
3201            "start_column": entry.start_column,
3202            "end_line": entry.end_line,
3203            "end_column": entry.end_column,
3204        },
3205    })
3206}
3207
3208fn resolve_string_id(snapshot: &UnifiedGraphSnapshot, id: StringId) -> Option<String> {
3209    snapshot.strings().resolve(id).map(|s| s.to_string())
3210}
3211
3212#[allow(clippy::too_many_lines)] // Exhaustive edge metadata mapping; keep variants together.
3213fn edge_metadata_json(
3214    snapshot: &UnifiedGraphSnapshot,
3215    kind: &UnifiedEdgeKind,
3216) -> serde_json::Value {
3217    use serde_json::json;
3218
3219    match kind {
3220        UnifiedEdgeKind::Defines
3221        | UnifiedEdgeKind::Contains
3222        | UnifiedEdgeKind::References
3223        | UnifiedEdgeKind::TypeOf { .. }
3224        | UnifiedEdgeKind::Inherits
3225        | UnifiedEdgeKind::Implements
3226        | UnifiedEdgeKind::WebAssemblyCall
3227        | UnifiedEdgeKind::GenericBound
3228        | UnifiedEdgeKind::AnnotatedWith
3229        | UnifiedEdgeKind::AnnotationParam
3230        | UnifiedEdgeKind::LambdaCaptures
3231        | UnifiedEdgeKind::ModuleExports
3232        | UnifiedEdgeKind::ModuleRequires
3233        | UnifiedEdgeKind::ModuleOpens
3234        | UnifiedEdgeKind::ModuleProvides
3235        | UnifiedEdgeKind::TypeArgument
3236        | UnifiedEdgeKind::ExtensionReceiver
3237        | UnifiedEdgeKind::CompanionOf
3238        | UnifiedEdgeKind::SealedPermit => json!({}),
3239        UnifiedEdgeKind::Calls {
3240            argument_count,
3241            is_async,
3242        } => json!({
3243            "argument_count": argument_count,
3244            "is_async": is_async,
3245        }),
3246        UnifiedEdgeKind::Imports { alias, is_wildcard } => json!({
3247            "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3248            "is_wildcard": is_wildcard,
3249        }),
3250        UnifiedEdgeKind::Exports { kind, alias } => json!({
3251            "kind": kind,
3252            "alias": alias.and_then(|id| resolve_string_id(snapshot, id)),
3253        }),
3254        UnifiedEdgeKind::LifetimeConstraint { constraint_kind } => json!({
3255            "constraint_kind": constraint_kind,
3256        }),
3257        UnifiedEdgeKind::TraitMethodBinding {
3258            trait_name,
3259            impl_type,
3260            is_ambiguous,
3261        } => json!({
3262            "trait_name": resolve_string_id(snapshot, *trait_name),
3263            "impl_type": resolve_string_id(snapshot, *impl_type),
3264            "is_ambiguous": is_ambiguous,
3265        }),
3266        UnifiedEdgeKind::MacroExpansion {
3267            expansion_kind,
3268            is_verified,
3269        } => json!({
3270            "expansion_kind": expansion_kind,
3271            "is_verified": is_verified,
3272        }),
3273        UnifiedEdgeKind::FfiCall { convention } => json!({
3274            "convention": convention,
3275        }),
3276        UnifiedEdgeKind::HttpRequest { method, url } => json!({
3277            "method": method,
3278            "url": url.and_then(|id| resolve_string_id(snapshot, id)),
3279        }),
3280        UnifiedEdgeKind::GrpcCall { service, method } => json!({
3281            "service": resolve_string_id(snapshot, *service),
3282            "method": resolve_string_id(snapshot, *method),
3283        }),
3284        UnifiedEdgeKind::DbQuery { query_type, table } => json!({
3285            "query_type": query_type,
3286            "table": table.and_then(|id| resolve_string_id(snapshot, id)),
3287        }),
3288        UnifiedEdgeKind::TableRead { table_name, schema } => json!({
3289            "table_name": resolve_string_id(snapshot, *table_name),
3290            "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3291        }),
3292        UnifiedEdgeKind::TableWrite {
3293            table_name,
3294            schema,
3295            operation,
3296        } => json!({
3297            "table_name": resolve_string_id(snapshot, *table_name),
3298            "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3299            "operation": operation,
3300        }),
3301        UnifiedEdgeKind::TriggeredBy {
3302            trigger_name,
3303            schema,
3304        } => json!({
3305            "trigger_name": resolve_string_id(snapshot, *trigger_name),
3306            "schema": schema.and_then(|id| resolve_string_id(snapshot, id)),
3307        }),
3308        UnifiedEdgeKind::MessageQueue { protocol, topic } => {
3309            let protocol_value = match protocol {
3310                MqProtocol::Kafka => Some("kafka".to_string()),
3311                MqProtocol::Sqs => Some("sqs".to_string()),
3312                MqProtocol::RabbitMq => Some("rabbit_mq".to_string()),
3313                MqProtocol::Nats => Some("nats".to_string()),
3314                MqProtocol::Redis => Some("redis".to_string()),
3315                MqProtocol::Other(id) => resolve_string_id(snapshot, *id),
3316            };
3317            json!({
3318                "protocol": protocol_value,
3319                "topic": topic.and_then(|id| resolve_string_id(snapshot, id)),
3320            })
3321        }
3322        UnifiedEdgeKind::WebSocket { event } => json!({
3323            "event": event.and_then(|id| resolve_string_id(snapshot, id)),
3324        }),
3325        UnifiedEdgeKind::GraphQLOperation { operation } => json!({
3326            "operation": resolve_string_id(snapshot, *operation),
3327        }),
3328        UnifiedEdgeKind::ProcessExec { command } => json!({
3329            "command": resolve_string_id(snapshot, *command),
3330        }),
3331        UnifiedEdgeKind::FileIpc { path_pattern } => json!({
3332            "path_pattern": path_pattern.and_then(|id| resolve_string_id(snapshot, id)),
3333        }),
3334        UnifiedEdgeKind::ProtocolCall { protocol, metadata } => json!({
3335            "protocol": resolve_string_id(snapshot, *protocol),
3336            "metadata": metadata.and_then(|id| resolve_string_id(snapshot, id)),
3337        }),
3338    }
3339}
3340
3341fn print_edge_metadata_text(snapshot: &UnifiedGraphSnapshot, kind: &UnifiedEdgeKind) {
3342    let metadata = edge_metadata_json(snapshot, kind);
3343    let Some(map) = metadata.as_object() else {
3344        return;
3345    };
3346    if map.is_empty() {
3347        return;
3348    }
3349    if let Ok(serialized) = serde_json::to_string(map) {
3350        println!("   Metadata: {serialized}");
3351    }
3352}
3353
3354fn parse_node_kind_filter(kinds: Option<&str>) -> Result<HashSet<UnifiedNodeKind>> {
3355    let mut filter = HashSet::new();
3356    let Some(kinds) = kinds else {
3357        return Ok(filter);
3358    };
3359    for raw in kinds.split(',') {
3360        let trimmed = raw.trim();
3361        if trimmed.is_empty() {
3362            continue;
3363        }
3364        let normalized = trimmed.to_ascii_lowercase();
3365        let Some(kind) = UnifiedNodeKind::parse(&normalized) else {
3366            return Err(anyhow::anyhow!(
3367                "Unknown node kind: {trimmed}. Valid kinds: {}",
3368                VALID_NODE_KIND_NAMES.join(", ")
3369            ));
3370        };
3371        filter.insert(kind);
3372    }
3373    Ok(filter)
3374}
3375
3376fn parse_edge_kind_filter(kinds: Option<&str>) -> Result<HashSet<String>> {
3377    let mut filter = HashSet::new();
3378    let Some(kinds) = kinds else {
3379        return Ok(filter);
3380    };
3381    for raw in kinds.split(',') {
3382        let trimmed = raw.trim();
3383        if trimmed.is_empty() {
3384            continue;
3385        }
3386        let normalized = trimmed.to_ascii_lowercase();
3387        if !VALID_EDGE_KIND_TAGS.contains(&normalized.as_str()) {
3388            return Err(anyhow::anyhow!(
3389                "Unknown edge kind: {trimmed}. Valid kinds: {}",
3390                VALID_EDGE_KIND_TAGS.join(", ")
3391            ));
3392        }
3393        filter.insert(normalized);
3394    }
3395    Ok(filter)
3396}
3397
3398fn display_languages(languages: &HashSet<Language>) -> String {
3399    let mut items: Vec<Language> = languages.iter().copied().collect();
3400    items.sort();
3401    items
3402        .into_iter()
3403        .map(|lang| lang.to_string())
3404        .collect::<Vec<_>>()
3405        .join(", ")
3406}
3407
3408fn parse_language_filter(languages: Option<&str>) -> Result<Vec<Language>> {
3409    if let Some(langs) = languages {
3410        langs.split(',').map(|s| parse_language(s.trim())).collect()
3411    } else {
3412        Ok(Vec::new())
3413    }
3414}
3415
3416fn parse_language(s: &str) -> Result<Language> {
3417    match s.to_lowercase().as_str() {
3418        // Phase 0 languages
3419        "javascript" | "js" => Ok(Language::JavaScript),
3420        "typescript" | "ts" => Ok(Language::TypeScript),
3421        "python" | "py" => Ok(Language::Python),
3422        "cpp" | "c++" | "cxx" => Ok(Language::Cpp),
3423        // Phase 1 languages
3424        "rust" | "rs" => Ok(Language::Rust),
3425        "go" => Ok(Language::Go),
3426        "java" => Ok(Language::Java),
3427        "c" => Ok(Language::C),
3428        "csharp" | "cs" => Ok(Language::CSharp),
3429        // Phase 2 languages
3430        "ruby" => Ok(Language::Ruby),
3431        "php" => Ok(Language::Php),
3432        "swift" => Ok(Language::Swift),
3433        // Phase 3 languages
3434        "kotlin" => Ok(Language::Kotlin),
3435        "scala" => Ok(Language::Scala),
3436        "sql" => Ok(Language::Sql),
3437        "dart" => Ok(Language::Dart),
3438        // Phase 5A languages
3439        "lua" => Ok(Language::Lua),
3440        "perl" => Ok(Language::Perl),
3441        "shell" | "bash" => Ok(Language::Shell),
3442        "groovy" => Ok(Language::Groovy),
3443        // Phase 5B languages
3444        "elixir" | "ex" => Ok(Language::Elixir),
3445        "r" => Ok(Language::R),
3446        // Phase 5C languages
3447        "haskell" | "hs" => Ok(Language::Haskell),
3448        "svelte" => Ok(Language::Svelte),
3449        "vue" => Ok(Language::Vue),
3450        "zig" => Ok(Language::Zig),
3451        // Other
3452        "http" => Ok(Language::Http),
3453        _ => bail!("Unknown language: {s}"),
3454    }
3455}
3456
3457// ===== Direct Callers/Callees =====
3458
3459/// Options for direct callers/callees lookup.
3460struct DirectCallOptions<'a> {
3461    /// Symbol name to search for.
3462    symbol: &'a str,
3463    /// Maximum number of results to return.
3464    limit: usize,
3465    /// Optional language filter (comma-separated).
3466    languages: Option<&'a str>,
3467    /// Show full file paths instead of relative.
3468    full_paths: bool,
3469    /// Output format: "text" or "json".
3470    format: &'a str,
3471    /// Enable verbose output.
3472    verbose: bool,
3473}
3474
3475/// Build a JSON row for a direct-caller/callee result.
3476fn direct_call_row(
3477    snapshot: &UnifiedGraphSnapshot,
3478    root: &Path,
3479    node_id: sqry_core::graph::unified::node::NodeId,
3480    full_paths: bool,
3481) -> Option<serde_json::Value> {
3482    use serde_json::json;
3483    let entry = snapshot.nodes().get(node_id)?;
3484    let strings = snapshot.strings();
3485    let files = snapshot.files();
3486    let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3487    let qualified_name = entry
3488        .qualified_name
3489        .and_then(|id| strings.resolve(id))
3490        .map_or_else(|| name.clone(), |s| s.to_string());
3491    let language = files
3492        .language_for_file(entry.file)
3493        .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3494    let file_path = files
3495        .resolve(entry.file)
3496        .map(|p| {
3497            if full_paths {
3498                p.display().to_string()
3499            } else {
3500                p.strip_prefix(root)
3501                    .unwrap_or(p.as_ref())
3502                    .display()
3503                    .to_string()
3504            }
3505        })
3506        .unwrap_or_default();
3507    Some(json!({
3508        "name": name,
3509        "qualified_name": qualified_name,
3510        "kind": format!("{:?}", entry.kind),
3511        "file": file_path,
3512        "line": entry.start_line,
3513        "language": language,
3514    }))
3515}
3516
3517/// Print a direct-callers / direct-callees result set in text or JSON.
3518fn emit_direct_call_output(
3519    symbol: &str,
3520    key: &'static str,
3521    label_noun: &'static str,
3522    rows: &[serde_json::Value],
3523    limit: usize,
3524    format: &str,
3525) -> Result<()> {
3526    use serde_json::json;
3527    if format == "json" {
3528        let output = json!({
3529            "symbol": symbol,
3530            key: rows,
3531            "total": rows.len(),
3532            "truncated": rows.len() >= limit,
3533        });
3534        println!("{}", serde_json::to_string_pretty(&output)?);
3535    } else {
3536        println!("{label_noun}s of '{symbol}':");
3537        println!();
3538        if rows.is_empty() {
3539            println!("  (no {label_noun}s found)");
3540        } else {
3541            for row in rows {
3542                let name = row["qualified_name"].as_str().unwrap_or("");
3543                let file = row["file"].as_str().unwrap_or("");
3544                let line = row["line"].as_u64().unwrap_or(0);
3545                println!("  {name} ({file}:{line})");
3546            }
3547            println!();
3548            println!("Total: {total} {label_noun}(s)", total = rows.len());
3549        }
3550    }
3551    Ok(())
3552}
3553
3554/// Find all direct callers of a symbol using the unified graph.
3555///
3556/// # Dispatch path (DB18)
3557///
3558/// `direct-callers` is a **name-keyed predicate** under the Phase 3C
3559/// dispatch taxonomy: the user supplies a symbol name and expects the set
3560/// of nodes that call *any* node with that name, under the
3561/// `graph_eval`-style convention (identical to the MCP `direct_callers`
3562/// tool). The handler routes through [`sqry_db::queries::dispatch::mcp_callers_query`],
3563/// which inverts sqry-db's planner-side naming (`CalleesQuery` keyed on
3564/// `X` returns nodes whose `callees` set includes `X` — i.e. callers of
3565/// `X`). See [`sqry_db::queries::dispatch`] for the full rationale.
3566///
3567/// Language + limit filters are applied MCP-style: sqry-db returns the
3568/// full candidate set, then this handler post-filters by language and
3569/// truncates at `limit`. The JSON schema is unchanged from the pre-DB18
3570/// inline-BFS implementation.
3571///
3572/// # Behavior shift (DB18)
3573///
3574/// The pre-DB18 implementation resolved the query through
3575/// [`find_nodes_by_name`] and walked reverse edges from each resolved
3576/// `NodeId`, so a query like `AlphaMarker::helper` returned callers of
3577/// exactly that node. The post-DB18 implementation uses sqry-db's
3578/// name-keyed, **segment-aware** `CalleesQuery`, which matches the
3579/// trailing method segment for `Calls` edges (identical to MCP's
3580/// `direct_callers` as of DB15 — see [`sqry_db::queries::relation`] for
3581/// `method_segment_matches`). On a fixture with two disjoint inherent
3582/// impls sharing a simple method name (`AlphaMarker::helper` and
3583/// `BetaMarker::helper`), a query for either qualified name now returns
3584/// callers of **both** methods, matching the MCP behavior. CLI and MCP
3585/// share one cache behavior after DB18. If stricter qualified-name
3586/// semantics is needed, users should pass the most specific unique
3587/// qualified name or use `sqry impact <symbol> --direct-only` (which
3588/// is NodeId-anchored and does not segment-broaden).
3589fn run_direct_callers_unified(
3590    graph: &UnifiedCodeGraph,
3591    root: &Path,
3592    options: &DirectCallOptions<'_>,
3593) -> Result<()> {
3594    let snapshot = std::sync::Arc::new(graph.snapshot());
3595    let files = snapshot.files();
3596
3597    let language_filter = parse_language_filter(options.languages)?
3598        .into_iter()
3599        .collect::<HashSet<_>>();
3600
3601    // Verify the symbol exists in the graph (matches the pre-DB18
3602    // not-found error contract). sqry-db's predicate returns an empty
3603    // set for unknown names, so we still resolve here for the error.
3604    let target_nodes = find_nodes_by_name(&snapshot, options.symbol);
3605    if target_nodes.is_empty() {
3606        bail!(
3607            "Symbol '{symbol}' not found in the graph",
3608            symbol = options.symbol
3609        );
3610    }
3611
3612    if options.verbose {
3613        eprintln!(
3614            "Found {count} node(s) matching symbol '{symbol}'",
3615            count = target_nodes.len(),
3616            symbol = options.symbol
3617        );
3618    }
3619
3620    // Route through sqry-db: name-keyed predicate, graph_eval-style
3621    // inversion (mcp_callers_query == db.get::<CalleesQuery>).
3622    // PN3 CLIENT_LOAD: opportunistic cold-load from workspace companion file.
3623    let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3624    let key = sqry_db::queries::RelationKey::exact(options.symbol);
3625    let caller_ids = sqry_db::queries::dispatch::mcp_callers_query(&db, &key);
3626
3627    // Post-filter: language filter + limit truncation.
3628    let mut rows = Vec::new();
3629    for &caller_id in caller_ids.iter() {
3630        if rows.len() >= options.limit {
3631            break;
3632        }
3633        let Some(entry) = snapshot.nodes().get(caller_id) else {
3634            continue;
3635        };
3636        if !language_filter.is_empty()
3637            && let Some(lang) = files.language_for_file(entry.file)
3638            && !language_filter.contains(&lang)
3639        {
3640            continue;
3641        }
3642        if let Some(row) = direct_call_row(&snapshot, root, caller_id, options.full_paths) {
3643            rows.push(row);
3644        }
3645    }
3646
3647    emit_direct_call_output(
3648        options.symbol,
3649        "callers",
3650        "caller",
3651        &rows,
3652        options.limit,
3653        options.format,
3654    )
3655}
3656
3657/// Find all direct callees of a symbol using the unified graph.
3658///
3659/// # Dispatch path (DB18)
3660///
3661/// `direct-callees` is a **name-keyed predicate** under the Phase 3C
3662/// dispatch taxonomy: the user supplies a symbol name and expects the set
3663/// of nodes that *any* node with that name calls, under the
3664/// `graph_eval`-style convention (identical to the MCP `direct_callees`
3665/// tool). The handler routes through [`sqry_db::queries::dispatch::mcp_callees_query`],
3666/// which inverts sqry-db's planner-side naming (`CallersQuery` keyed on
3667/// `X` returns nodes whose `callers` set includes `X` — i.e. nodes that
3668/// `X` calls). See [`sqry_db::queries::dispatch`] for the full rationale.
3669///
3670/// Language + limit filters are applied MCP-style: sqry-db returns the
3671/// full candidate set, then this handler post-filters by language and
3672/// truncates at `limit`. The JSON schema is unchanged from the pre-DB18
3673/// inline-edge-walk implementation.
3674///
3675/// # Behavior shift (DB18)
3676///
3677/// See [`run_direct_callers_unified`]'s docstring — the same name-keyed,
3678/// segment-aware semantic applies here. Ambiguous simple names (or
3679/// qualified names that share a trailing method segment with another
3680/// node) now return the union of callees across every matching node,
3681/// matching MCP's DB15 behavior.
3682fn run_direct_callees_unified(
3683    graph: &UnifiedCodeGraph,
3684    root: &Path,
3685    options: &DirectCallOptions<'_>,
3686) -> Result<()> {
3687    let snapshot = std::sync::Arc::new(graph.snapshot());
3688    let files = snapshot.files();
3689
3690    let language_filter = parse_language_filter(options.languages)?
3691        .into_iter()
3692        .collect::<HashSet<_>>();
3693
3694    // Verify the symbol exists (matches pre-DB18 not-found contract).
3695    let source_nodes = find_nodes_by_name(&snapshot, options.symbol);
3696    if source_nodes.is_empty() {
3697        bail!(
3698            "Symbol '{symbol}' not found in the graph",
3699            symbol = options.symbol
3700        );
3701    }
3702
3703    if options.verbose {
3704        eprintln!(
3705            "Found {count} node(s) matching symbol '{symbol}'",
3706            count = source_nodes.len(),
3707            symbol = options.symbol
3708        );
3709    }
3710
3711    // Route through sqry-db: name-keyed predicate, graph_eval-style
3712    // inversion (mcp_callees_query == db.get::<CallersQuery>).
3713    // PN3 CLIENT_LOAD: opportunistic cold-load from workspace companion file.
3714    let db = sqry_db::queries::dispatch::make_query_db_cold(std::sync::Arc::clone(&snapshot), root);
3715    let key = sqry_db::queries::RelationKey::exact(options.symbol);
3716    let callee_ids = sqry_db::queries::dispatch::mcp_callees_query(&db, &key);
3717
3718    // Post-filter: language filter + limit truncation.
3719    let mut rows = Vec::new();
3720    for &callee_id in callee_ids.iter() {
3721        if rows.len() >= options.limit {
3722            break;
3723        }
3724        let Some(entry) = snapshot.nodes().get(callee_id) else {
3725            continue;
3726        };
3727        if !language_filter.is_empty()
3728            && let Some(lang) = files.language_for_file(entry.file)
3729            && !language_filter.contains(&lang)
3730        {
3731            continue;
3732        }
3733        if let Some(row) = direct_call_row(&snapshot, root, callee_id, options.full_paths) {
3734            rows.push(row);
3735        }
3736    }
3737
3738    emit_direct_call_output(
3739        options.symbol,
3740        "callees",
3741        "callee",
3742        &rows,
3743        options.limit,
3744        options.format,
3745    )
3746}
3747
3748// ===== Call Hierarchy =====
3749
3750/// Options for call hierarchy display.
3751struct CallHierarchyOptions<'a> {
3752    /// Symbol name to search for.
3753    symbol: &'a str,
3754    /// Maximum traversal depth.
3755    max_depth: usize,
3756    /// Direction: "incoming", "outgoing", or "both".
3757    direction: &'a str,
3758    /// Optional language filter (comma-separated).
3759    languages: Option<&'a str>,
3760    /// Show full file paths instead of relative.
3761    full_paths: bool,
3762    /// Output format: "text" or "json".
3763    format: &'a str,
3764    /// Enable verbose output.
3765    verbose: bool,
3766}
3767
3768/// Show call hierarchy for a symbol using the unified graph.
3769fn run_call_hierarchy_unified(
3770    graph: &UnifiedCodeGraph,
3771    root: &Path,
3772    options: &CallHierarchyOptions<'_>,
3773) -> Result<()> {
3774    use serde_json::json;
3775
3776    let snapshot = graph.snapshot();
3777
3778    let language_filter = parse_language_filter(options.languages)?
3779        .into_iter()
3780        .collect::<HashSet<_>>();
3781
3782    // Find the node(s) matching the symbol
3783    let start_nodes = find_nodes_by_name(&snapshot, options.symbol);
3784
3785    if start_nodes.is_empty() {
3786        bail!("Symbol '{}' not found in the graph", options.symbol);
3787    }
3788
3789    if options.verbose {
3790        eprintln!(
3791            "Found {} node(s) matching symbol '{}' (direction={})",
3792            start_nodes.len(),
3793            options.symbol,
3794            options.direction
3795        );
3796    }
3797
3798    let include_incoming = options.direction == "incoming" || options.direction == "both";
3799    let include_outgoing = options.direction == "outgoing" || options.direction == "both";
3800
3801    let mut result = json!({
3802        "symbol": options.symbol,
3803        "direction": options.direction,
3804        "max_depth": options.max_depth
3805    });
3806
3807    // Build incoming hierarchy (callers)
3808    if include_incoming {
3809        let incoming = build_call_hierarchy_tree(
3810            &snapshot,
3811            &start_nodes,
3812            options.max_depth,
3813            true, // incoming
3814            &language_filter,
3815            root,
3816            options.full_paths,
3817        );
3818        result["incoming"] = incoming;
3819    }
3820
3821    // Build outgoing hierarchy (callees)
3822    if include_outgoing {
3823        let outgoing = build_call_hierarchy_tree(
3824            &snapshot,
3825            &start_nodes,
3826            options.max_depth,
3827            false, // outgoing
3828            &language_filter,
3829            root,
3830            options.full_paths,
3831        );
3832        result["outgoing"] = outgoing;
3833    }
3834
3835    if options.format == "json" {
3836        println!("{}", serde_json::to_string_pretty(&result)?);
3837    } else {
3838        println!("Call hierarchy for '{symbol}':", symbol = options.symbol);
3839        println!();
3840
3841        if include_incoming {
3842            println!("Incoming calls (callers):");
3843            if let Some(incoming) = result["incoming"].as_array() {
3844                print_hierarchy_text(incoming, 1);
3845            }
3846            println!();
3847        }
3848
3849        if include_outgoing {
3850            println!("Outgoing calls (callees):");
3851            if let Some(outgoing) = result["outgoing"].as_array() {
3852                print_hierarchy_text(outgoing, 1);
3853            }
3854        }
3855    }
3856
3857    Ok(())
3858}
3859
3860/// Build a call hierarchy tree.
3861#[allow(clippy::items_after_statements, clippy::too_many_lines)]
3862fn build_call_hierarchy_tree(
3863    snapshot: &UnifiedGraphSnapshot,
3864    start_nodes: &[sqry_core::graph::unified::node::NodeId],
3865    max_depth: usize,
3866    incoming: bool,
3867    language_filter: &HashSet<Language>,
3868    root: &Path,
3869    full_paths: bool,
3870) -> serde_json::Value {
3871    use serde_json::json;
3872    use sqry_core::graph::unified::node::NodeId as UnifiedNodeId;
3873
3874    let _strings = snapshot.strings();
3875    let _files = snapshot.files();
3876
3877    let mut result = Vec::new();
3878    let mut visited = HashSet::new();
3879
3880    /// Configuration for call hierarchy traversal.
3881    struct TraversalConfig<'a> {
3882        max_depth: usize,
3883        incoming: bool,
3884        language_filter: &'a HashSet<Language>,
3885        root: &'a Path,
3886        full_paths: bool,
3887    }
3888
3889    fn traverse(
3890        snapshot: &UnifiedGraphSnapshot,
3891        node_id: UnifiedNodeId,
3892        depth: usize,
3893        config: &TraversalConfig<'_>,
3894        visited: &mut HashSet<UnifiedNodeId>,
3895    ) -> serde_json::Value {
3896        let strings = snapshot.strings();
3897        let files = snapshot.files();
3898
3899        let Some(entry) = snapshot.nodes().get(node_id) else {
3900            return json!(null);
3901        };
3902
3903        let name = strings.resolve(entry.name).unwrap_or_default().to_string();
3904        let qualified_name = entry
3905            .qualified_name
3906            .and_then(|id| strings.resolve(id))
3907            .map_or_else(|| name.clone(), |s| s.to_string());
3908        let language = files
3909            .language_for_file(entry.file)
3910            .map_or_else(|| "unknown".to_string(), |l| l.to_string());
3911        let file_path = files
3912            .resolve(entry.file)
3913            .map(|p| {
3914                if config.full_paths {
3915                    p.display().to_string()
3916                } else {
3917                    p.strip_prefix(config.root)
3918                        .unwrap_or(p.as_ref())
3919                        .display()
3920                        .to_string()
3921                }
3922            })
3923            .unwrap_or_default();
3924
3925        let mut node_json = json!({
3926            "name": name,
3927            "qualified_name": qualified_name,
3928            "kind": format!("{:?}", entry.kind),
3929            "file": file_path,
3930            "line": entry.start_line,
3931            "language": language
3932        });
3933
3934        // Recurse if not at max depth and not visited
3935        if depth < config.max_depth && !visited.contains(&node_id) {
3936            visited.insert(node_id);
3937
3938            let mut children = Vec::new();
3939            let edges = if config.incoming {
3940                snapshot.edges().reverse().edges_from(node_id)
3941            } else {
3942                snapshot.edges().edges_from(node_id)
3943            };
3944
3945            for edge_ref in edges {
3946                if !matches!(edge_ref.kind, UnifiedEdgeKind::Calls { .. }) {
3947                    continue;
3948                }
3949
3950                let related_id = edge_ref.target;
3951
3952                // Apply language filter
3953                if !config.language_filter.is_empty()
3954                    && let Some(related_entry) = snapshot.nodes().get(related_id)
3955                    && let Some(lang) = files.language_for_file(related_entry.file)
3956                    && !config.language_filter.contains(&lang)
3957                {
3958                    continue;
3959                }
3960
3961                let child = traverse(snapshot, related_id, depth + 1, config, visited);
3962
3963                if !child.is_null() {
3964                    children.push(child);
3965                }
3966            }
3967
3968            if !children.is_empty() {
3969                node_json["children"] = json!(children);
3970            }
3971        }
3972
3973        node_json
3974    }
3975
3976    let config = TraversalConfig {
3977        max_depth,
3978        incoming,
3979        language_filter,
3980        root,
3981        full_paths,
3982    };
3983
3984    for &node_id in start_nodes {
3985        let tree = traverse(snapshot, node_id, 0, &config, &mut visited);
3986        if !tree.is_null() {
3987            result.push(tree);
3988        }
3989    }
3990
3991    json!(result)
3992}
3993
3994/// Print hierarchy in text format.
3995fn print_hierarchy_text(nodes: &[serde_json::Value], indent: usize) {
3996    let prefix = "  ".repeat(indent);
3997    for node in nodes {
3998        let name = node["qualified_name"].as_str().unwrap_or("?");
3999        let file = node["file"].as_str().unwrap_or("?");
4000        let line = node["line"].as_u64().unwrap_or(0);
4001        println!("{prefix}{name} ({file}:{line})");
4002
4003        if let Some(children) = node["children"].as_array() {
4004            print_hierarchy_text(children, indent + 1);
4005        }
4006    }
4007}
4008
4009// ===== Is In Cycle =====
4010
4011/// Check if a symbol is in a cycle using sqry-db's cycle queries.
4012///
4013/// # Dispatch path (DB19)
4014///
4015/// `is-in-cycle` is a **hybrid** under the Phase 3C dispatch taxonomy:
4016/// the symbol name is strictly resolved to a single `NodeId` up front
4017/// (mirroring the MCP policy in
4018/// [`sqry_mcp::execution::tools::analysis::execute_is_node_in_cycle`]),
4019/// then dispatches to two sqry-db queries:
4020///
4021/// 1. [`sqry_db::queries::IsInCycleQuery`] keyed on the resolved `NodeId`
4022///    for the boolean answer (cached per-snapshot).
4023/// 2. On a `true` answer and when `--show-cycle` is requested, a
4024///    follow-up [`sqry_db::queries::CyclesQuery`] with `max_results =
4025///    usize::MAX` to fetch the containing cycle. The SCC table is already
4026///    warmed by `IsInCycleQuery`, so this is O(cycles) filtering work on
4027///    a hot cache. Uncapped matches the DB17 peer-review (Low 1)
4028///    decision for the MCP surface: capping could surface
4029///    `in_cycle=true, cycle=null` corner cases which are correctness bugs.
4030///
4031/// Ambiguous simple names are rejected up front (strict resolution) —
4032/// the pre-DB19 implementation walked every candidate and merged the
4033/// results, which returned nondeterministic output for same-name
4034/// candidates in disjoint modules.
4035fn run_is_in_cycle_unified(
4036    graph: &UnifiedCodeGraph,
4037    root: &Path,
4038    symbol: &str,
4039    cycle_type: &str,
4040    show_cycle: bool,
4041    format: &str,
4042    verbose: bool,
4043) -> Result<()> {
4044    use serde_json::json;
4045    use sqry_core::graph::unified::{
4046        FileScope, ResolutionMode, SymbolQuery, SymbolResolutionOutcome,
4047    };
4048    use sqry_core::query::CircularType;
4049    use std::sync::Arc;
4050
4051    // Parse cycle type. We accept the canonical plural forms (`calls`,
4052    // `imports`, `modules`) plus the DB17 MCP surface's singular aliases
4053    // so the CLI and MCP surfaces stay in lock-step. The pre-DB19 CLI
4054    // also accepted `all` meaning "cycles across any edge kind" — we
4055    // preserve that by running two sqry-db queries (`Calls` then
4056    // `Imports`) and unioning the results.
4057    let cycle_types: Vec<CircularType> = if cycle_type.eq_ignore_ascii_case("all") {
4058        vec![CircularType::Calls, CircularType::Imports]
4059    } else {
4060        let parsed = CircularType::try_parse(cycle_type).with_context(|| {
4061            format!("Invalid cycle type: {cycle_type}. Use: calls, imports, modules, all")
4062        })?;
4063        vec![parsed]
4064    };
4065
4066    let snapshot = Arc::new(graph.snapshot());
4067
4068    // Strict resolution: reject ambiguous simple names up front rather
4069    // than answering "in a cycle?" on an arbitrary candidate. Mirrors
4070    // the DB17 MCP policy for `is_node_in_cycle` / `dependency_impact`.
4071    let target_id = match snapshot.resolve_symbol(&SymbolQuery {
4072        symbol,
4073        file_scope: FileScope::Any,
4074        mode: ResolutionMode::Strict,
4075    }) {
4076        SymbolResolutionOutcome::Resolved(node_id) => node_id,
4077        SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
4078            bail!("Symbol '{symbol}' not found in the graph");
4079        }
4080        SymbolResolutionOutcome::Ambiguous(candidates) => {
4081            bail!(
4082                "Symbol '{symbol}' is ambiguous ({} candidates). Use a canonical qualified name.",
4083                candidates.len()
4084            );
4085        }
4086    };
4087
4088    if verbose {
4089        eprintln!(
4090            "Checking if symbol '{}' ({:?}) is in a {} cycle",
4091            symbol, target_id, cycle_type
4092        );
4093    }
4094
4095    // Route through sqry-db: `IsInCycleQuery` is the hybrid cycle
4096    // predicate in the planner taxonomy, cached per-snapshot.
4097    // Predicate bounds use the pre-DB19 default `max_results = 100`
4098    // for the predicate; the containing-cycle materialization below
4099    // uses `usize::MAX` so `in_cycle=true` never coexists with
4100    // `cycle=null` (matches DB17 MCP peer-review Low 1 fix).
4101    //
4102    // For `cycle_type == "all"`, we iterate over both Calls and Imports
4103    // circular types and union the results. This preserves the
4104    // pre-DB19 CLI semantic (which followed both edge kinds in a
4105    // single DFS) without exposing a union variant in sqry-db.
4106    // PN3 CLIENT_LOAD: opportunistic cold-load from workspace companion file.
4107    let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), root);
4108    let predicate_bounds = sqry_db::queries::CycleBounds {
4109        min_depth: 2,
4110        max_depth: None,
4111        max_results: 100,
4112        should_include_self_loops: false,
4113    };
4114    let mut in_cycle = false;
4115    let mut found_cycles: Vec<serde_json::Value> = Vec::new();
4116    for &ct in &cycle_types {
4117        if db.get::<sqry_db::queries::IsInCycleQuery>(&sqry_db::queries::IsInCycleKey {
4118            node_id: target_id,
4119            circular_type: ct,
4120            bounds: predicate_bounds,
4121        }) {
4122            in_cycle = true;
4123            if show_cycle {
4124                let cycle_lookup_bounds = sqry_db::queries::CycleBounds {
4125                    min_depth: 2,
4126                    max_depth: None,
4127                    max_results: usize::MAX,
4128                    should_include_self_loops: false,
4129                };
4130                let all_cycles =
4131                    db.get::<sqry_db::queries::CyclesQuery>(&sqry_db::queries::CyclesKey {
4132                        circular_type: ct,
4133                        bounds: cycle_lookup_bounds,
4134                    });
4135                if let Some(component) = all_cycles
4136                    .iter()
4137                    .find(|component| component.contains(&target_id))
4138                {
4139                    let strings = snapshot.strings();
4140                    let cycle_names: Vec<String> = component
4141                        .iter()
4142                        .filter_map(|&node_id| {
4143                            snapshot.get_node(node_id).and_then(|entry| {
4144                                entry
4145                                    .qualified_name
4146                                    .and_then(|id| strings.resolve(id))
4147                                    .or_else(|| strings.resolve(entry.name))
4148                                    .map(|s| s.to_string())
4149                            })
4150                        })
4151                        .collect();
4152                    found_cycles.push(json!({
4153                        "node": format!("{target_id:?}"),
4154                        "cycle": cycle_names
4155                    }));
4156                }
4157            }
4158        }
4159    }
4160
4161    if format == "json" {
4162        let output = if show_cycle {
4163            json!({
4164                "symbol": symbol,
4165                "in_cycle": in_cycle,
4166                "cycle_type": cycle_type,
4167                "cycles": found_cycles
4168            })
4169        } else {
4170            json!({
4171                "symbol": symbol,
4172                "in_cycle": in_cycle,
4173                "cycle_type": cycle_type
4174            })
4175        };
4176        println!("{}", serde_json::to_string_pretty(&output)?);
4177    } else if in_cycle {
4178        println!("Symbol '{symbol}' IS in a {cycle_type} cycle.");
4179        if show_cycle {
4180            for (i, cycle) in found_cycles.iter().enumerate() {
4181                println!();
4182                println!("Cycle {}:", i + 1);
4183                if let Some(names) = cycle["cycle"].as_array() {
4184                    for (j, name) in names.iter().enumerate() {
4185                        let prefix = if j == 0 { "  " } else { "  → " };
4186                        println!("{prefix}{name}", name = name.as_str().unwrap_or("?"));
4187                    }
4188                    // Show the loop back.
4189                    if let Some(first) = names.first() {
4190                        println!("  → {} (cycle)", first.as_str().unwrap_or("?"));
4191                    }
4192                }
4193            }
4194        }
4195    } else {
4196        println!("Symbol '{symbol}' is NOT in any {cycle_type} cycle.");
4197    }
4198
4199    Ok(())
4200}
4201
4202#[cfg(test)]
4203mod tests {
4204    use super::*;
4205
4206    // ==========================================================================
4207    // parse_language tests
4208    // ==========================================================================
4209
4210    #[test]
4211    fn test_parse_language_javascript_variants() {
4212        assert_eq!(parse_language("javascript").unwrap(), Language::JavaScript);
4213        assert_eq!(parse_language("js").unwrap(), Language::JavaScript);
4214        assert_eq!(parse_language("JavaScript").unwrap(), Language::JavaScript);
4215        assert_eq!(parse_language("JS").unwrap(), Language::JavaScript);
4216    }
4217
4218    #[test]
4219    fn test_parse_language_typescript_variants() {
4220        assert_eq!(parse_language("typescript").unwrap(), Language::TypeScript);
4221        assert_eq!(parse_language("ts").unwrap(), Language::TypeScript);
4222        assert_eq!(parse_language("TypeScript").unwrap(), Language::TypeScript);
4223    }
4224
4225    #[test]
4226    fn test_parse_language_python_variants() {
4227        assert_eq!(parse_language("python").unwrap(), Language::Python);
4228        assert_eq!(parse_language("py").unwrap(), Language::Python);
4229        assert_eq!(parse_language("PYTHON").unwrap(), Language::Python);
4230    }
4231
4232    #[test]
4233    fn test_parse_language_cpp_variants() {
4234        assert_eq!(parse_language("cpp").unwrap(), Language::Cpp);
4235        assert_eq!(parse_language("c++").unwrap(), Language::Cpp);
4236        assert_eq!(parse_language("cxx").unwrap(), Language::Cpp);
4237        assert_eq!(parse_language("CPP").unwrap(), Language::Cpp);
4238    }
4239
4240    #[test]
4241    fn test_parse_language_rust_variants() {
4242        assert_eq!(parse_language("rust").unwrap(), Language::Rust);
4243        assert_eq!(parse_language("rs").unwrap(), Language::Rust);
4244    }
4245
4246    #[test]
4247    fn test_parse_language_go() {
4248        assert_eq!(parse_language("go").unwrap(), Language::Go);
4249        assert_eq!(parse_language("Go").unwrap(), Language::Go);
4250    }
4251
4252    #[test]
4253    fn test_parse_language_java() {
4254        assert_eq!(parse_language("java").unwrap(), Language::Java);
4255    }
4256
4257    #[test]
4258    fn test_parse_language_c() {
4259        assert_eq!(parse_language("c").unwrap(), Language::C);
4260        assert_eq!(parse_language("C").unwrap(), Language::C);
4261    }
4262
4263    #[test]
4264    fn test_parse_language_csharp_variants() {
4265        assert_eq!(parse_language("csharp").unwrap(), Language::CSharp);
4266        assert_eq!(parse_language("cs").unwrap(), Language::CSharp);
4267        assert_eq!(parse_language("CSharp").unwrap(), Language::CSharp);
4268    }
4269
4270    #[test]
4271    fn test_parse_language_ruby() {
4272        assert_eq!(parse_language("ruby").unwrap(), Language::Ruby);
4273    }
4274
4275    #[test]
4276    fn test_parse_language_php() {
4277        assert_eq!(parse_language("php").unwrap(), Language::Php);
4278    }
4279
4280    #[test]
4281    fn test_parse_language_swift() {
4282        assert_eq!(parse_language("swift").unwrap(), Language::Swift);
4283    }
4284
4285    #[test]
4286    fn test_parse_language_kotlin() {
4287        assert_eq!(parse_language("kotlin").unwrap(), Language::Kotlin);
4288    }
4289
4290    #[test]
4291    fn test_parse_language_scala() {
4292        assert_eq!(parse_language("scala").unwrap(), Language::Scala);
4293    }
4294
4295    #[test]
4296    fn test_parse_language_sql() {
4297        assert_eq!(parse_language("sql").unwrap(), Language::Sql);
4298    }
4299
4300    #[test]
4301    fn test_parse_language_dart() {
4302        assert_eq!(parse_language("dart").unwrap(), Language::Dart);
4303    }
4304
4305    #[test]
4306    fn test_parse_language_lua() {
4307        assert_eq!(parse_language("lua").unwrap(), Language::Lua);
4308    }
4309
4310    #[test]
4311    fn test_parse_language_perl() {
4312        assert_eq!(parse_language("perl").unwrap(), Language::Perl);
4313    }
4314
4315    #[test]
4316    fn test_parse_language_shell_variants() {
4317        assert_eq!(parse_language("shell").unwrap(), Language::Shell);
4318        assert_eq!(parse_language("bash").unwrap(), Language::Shell);
4319    }
4320
4321    #[test]
4322    fn test_parse_language_groovy() {
4323        assert_eq!(parse_language("groovy").unwrap(), Language::Groovy);
4324    }
4325
4326    #[test]
4327    fn test_parse_language_elixir_variants() {
4328        assert_eq!(parse_language("elixir").unwrap(), Language::Elixir);
4329        assert_eq!(parse_language("ex").unwrap(), Language::Elixir);
4330    }
4331
4332    #[test]
4333    fn test_parse_language_r() {
4334        assert_eq!(parse_language("r").unwrap(), Language::R);
4335        assert_eq!(parse_language("R").unwrap(), Language::R);
4336    }
4337
4338    #[test]
4339    fn test_parse_language_haskell_variants() {
4340        assert_eq!(parse_language("haskell").unwrap(), Language::Haskell);
4341        assert_eq!(parse_language("hs").unwrap(), Language::Haskell);
4342    }
4343
4344    #[test]
4345    fn test_parse_language_svelte() {
4346        assert_eq!(parse_language("svelte").unwrap(), Language::Svelte);
4347    }
4348
4349    #[test]
4350    fn test_parse_language_vue() {
4351        assert_eq!(parse_language("vue").unwrap(), Language::Vue);
4352    }
4353
4354    #[test]
4355    fn test_parse_language_zig() {
4356        assert_eq!(parse_language("zig").unwrap(), Language::Zig);
4357    }
4358
4359    #[test]
4360    fn test_parse_language_http() {
4361        assert_eq!(parse_language("http").unwrap(), Language::Http);
4362    }
4363
4364    #[test]
4365    fn test_parse_language_unknown() {
4366        let result = parse_language("unknown_language");
4367        assert!(result.is_err());
4368        assert!(result.unwrap_err().to_string().contains("Unknown language"));
4369    }
4370
4371    // ==========================================================================
4372    // parse_language_filter tests
4373    // ==========================================================================
4374
4375    #[test]
4376    fn test_parse_language_filter_none() {
4377        let result = parse_language_filter(None).unwrap();
4378        assert!(result.is_empty());
4379    }
4380
4381    #[test]
4382    fn test_parse_language_filter_single() {
4383        let result = parse_language_filter(Some("rust")).unwrap();
4384        assert_eq!(result.len(), 1);
4385        assert_eq!(result[0], Language::Rust);
4386    }
4387
4388    #[test]
4389    fn test_parse_language_filter_multiple() {
4390        let result = parse_language_filter(Some("rust,python,go")).unwrap();
4391        assert_eq!(result.len(), 3);
4392        assert!(result.contains(&Language::Rust));
4393        assert!(result.contains(&Language::Python));
4394        assert!(result.contains(&Language::Go));
4395    }
4396
4397    #[test]
4398    fn test_parse_language_filter_with_spaces() {
4399        let result = parse_language_filter(Some("rust , python , go")).unwrap();
4400        assert_eq!(result.len(), 3);
4401    }
4402
4403    #[test]
4404    fn test_parse_language_filter_with_aliases() {
4405        let result = parse_language_filter(Some("js,ts,py")).unwrap();
4406        assert_eq!(result.len(), 3);
4407        assert!(result.contains(&Language::JavaScript));
4408        assert!(result.contains(&Language::TypeScript));
4409        assert!(result.contains(&Language::Python));
4410    }
4411
4412    #[test]
4413    fn test_parse_language_filter_invalid() {
4414        let result = parse_language_filter(Some("rust,invalid,python"));
4415        assert!(result.is_err());
4416    }
4417
4418    // ==========================================================================
4419    // parse_language_filter_unified tests
4420    // ==========================================================================
4421
4422    #[test]
4423    fn test_parse_language_filter_unified_none() {
4424        let result = parse_language_filter_unified(None);
4425        assert!(result.is_empty());
4426    }
4427
4428    #[test]
4429    fn test_parse_language_filter_unified_single() {
4430        let result = parse_language_filter_unified(Some("rust"));
4431        assert_eq!(result.len(), 1);
4432        assert_eq!(result[0], "rust");
4433    }
4434
4435    #[test]
4436    fn test_parse_language_filter_unified_multiple() {
4437        let result = parse_language_filter_unified(Some("rust,python,go"));
4438        assert_eq!(result.len(), 3);
4439        assert!(result.contains(&"rust".to_string()));
4440        assert!(result.contains(&"python".to_string()));
4441        assert!(result.contains(&"go".to_string()));
4442    }
4443
4444    #[test]
4445    fn test_parse_language_filter_unified_with_spaces() {
4446        let result = parse_language_filter_unified(Some(" rust , python "));
4447        assert_eq!(result.len(), 2);
4448        assert!(result.contains(&"rust".to_string()));
4449        assert!(result.contains(&"python".to_string()));
4450    }
4451
4452    // ==========================================================================
4453    // parse_language_filter_for_complexity tests
4454    // ==========================================================================
4455
4456    #[test]
4457    fn test_parse_language_filter_for_complexity_none() {
4458        let result = parse_language_filter_for_complexity(None).unwrap();
4459        assert!(result.is_empty());
4460    }
4461
4462    #[test]
4463    fn test_parse_language_filter_for_complexity_single() {
4464        let result = parse_language_filter_for_complexity(Some("rust")).unwrap();
4465        assert_eq!(result.len(), 1);
4466        assert_eq!(result[0], Language::Rust);
4467    }
4468
4469    #[test]
4470    fn test_parse_language_filter_for_complexity_multiple() {
4471        let result = parse_language_filter_for_complexity(Some("rust,python")).unwrap();
4472        assert_eq!(result.len(), 2);
4473    }
4474
4475    // ==========================================================================
4476    // display_languages tests
4477    // ==========================================================================
4478
4479    #[test]
4480    fn test_display_languages_empty() {
4481        let languages: HashSet<Language> = HashSet::new();
4482        assert_eq!(display_languages(&languages), "");
4483    }
4484
4485    #[test]
4486    fn test_display_languages_single() {
4487        let mut languages = HashSet::new();
4488        languages.insert(Language::Rust);
4489        let result = display_languages(&languages);
4490        assert_eq!(result, "rust");
4491    }
4492
4493    #[test]
4494    fn test_display_languages_multiple() {
4495        let mut languages = HashSet::new();
4496        languages.insert(Language::Rust);
4497        languages.insert(Language::Python);
4498        let result = display_languages(&languages);
4499        // Result should be sorted (py comes before rust)
4500        assert!(result.contains("py"));
4501        assert!(result.contains("rust"));
4502        assert!(result.contains(", "));
4503    }
4504
4505    // ==========================================================================
4506    // edge_kind_matches_unified tests
4507    // ==========================================================================
4508
4509    #[test]
4510    fn test_edge_kind_matches_unified_calls() {
4511        let kind = UnifiedEdgeKind::Calls {
4512            argument_count: 2,
4513            is_async: false,
4514        };
4515        assert!(edge_kind_matches_unified(&kind, "calls"));
4516        assert!(edge_kind_matches_unified(&kind, "Calls"));
4517        assert!(edge_kind_matches_unified(&kind, "CALLS"));
4518    }
4519
4520    #[test]
4521    fn test_edge_kind_matches_unified_imports() {
4522        let kind = UnifiedEdgeKind::Imports {
4523            alias: None,
4524            is_wildcard: false,
4525        };
4526        assert!(edge_kind_matches_unified(&kind, "imports"));
4527        assert!(edge_kind_matches_unified(&kind, "import"));
4528    }
4529
4530    #[test]
4531    fn test_edge_kind_matches_unified_no_match() {
4532        let kind = UnifiedEdgeKind::Calls {
4533            argument_count: 0,
4534            is_async: false,
4535        };
4536        assert!(!edge_kind_matches_unified(&kind, "imports"));
4537        assert!(!edge_kind_matches_unified(&kind, "exports"));
4538    }
4539
4540    #[test]
4541    fn test_edge_kind_matches_unified_partial() {
4542        let kind = UnifiedEdgeKind::Calls {
4543            argument_count: 1,
4544            is_async: true,
4545        };
4546        // "async" should match since the debug output contains "is_async: true"
4547        assert!(edge_kind_matches_unified(&kind, "async"));
4548    }
4549
4550    // ==========================================================================
4551    // parse_node_kind_filter tests
4552    // ==========================================================================
4553
4554    #[test]
4555    fn test_parse_node_kind_filter_none() {
4556        let result = parse_node_kind_filter(None).unwrap();
4557        assert!(result.is_empty());
4558    }
4559
4560    #[test]
4561    fn test_parse_node_kind_filter_valid() {
4562        let result = parse_node_kind_filter(Some("Function,macro,call_site")).unwrap();
4563        assert_eq!(result.len(), 3);
4564        assert!(result.contains(&UnifiedNodeKind::Function));
4565        assert!(result.contains(&UnifiedNodeKind::Macro));
4566        assert!(result.contains(&UnifiedNodeKind::CallSite));
4567    }
4568
4569    #[test]
4570    fn test_parse_node_kind_filter_invalid() {
4571        let result = parse_node_kind_filter(Some("function,unknown"));
4572        assert!(result.is_err());
4573    }
4574
4575    // ==========================================================================
4576    // parse_edge_kind_filter tests
4577    // ==========================================================================
4578
4579    #[test]
4580    fn test_parse_edge_kind_filter_none() {
4581        let result = parse_edge_kind_filter(None).unwrap();
4582        assert!(result.is_empty());
4583    }
4584
4585    #[test]
4586    fn test_parse_edge_kind_filter_valid() {
4587        let result = parse_edge_kind_filter(Some("calls,table_read,HTTP_REQUEST")).unwrap();
4588        assert!(result.contains("calls"));
4589        assert!(result.contains("table_read"));
4590        assert!(result.contains("http_request"));
4591    }
4592
4593    #[test]
4594    fn test_parse_edge_kind_filter_invalid() {
4595        let result = parse_edge_kind_filter(Some("calls,unknown_edge"));
4596        assert!(result.is_err());
4597    }
4598
4599    // ==========================================================================
4600    // normalize_graph_limit tests
4601    // ==========================================================================
4602
4603    #[test]
4604    fn test_normalize_graph_limit_default_on_zero() {
4605        assert_eq!(normalize_graph_limit(0), DEFAULT_GRAPH_LIST_LIMIT);
4606    }
4607
4608    #[test]
4609    fn test_normalize_graph_limit_clamps_max() {
4610        assert_eq!(
4611            normalize_graph_limit(MAX_GRAPH_LIST_LIMIT + 1),
4612            MAX_GRAPH_LIST_LIMIT
4613        );
4614    }
4615
4616    // ==========================================================================
4617    // find_path_unified_bfs tests (kernel-backed)
4618    // ==========================================================================
4619
4620    #[test]
4621    fn test_find_path_no_graph_returns_none() {
4622        use sqry_core::graph::unified::concurrent::CodeGraph;
4623        use sqry_core::graph::unified::node::NodeId;
4624
4625        let graph = CodeGraph::new();
4626        let snapshot = graph.snapshot();
4627        let starts = vec![NodeId::new(0, 0)];
4628        let targets: HashSet<NodeId> = [NodeId::new(1, 0)].into_iter().collect();
4629        let filter: HashSet<Language> = HashSet::new();
4630
4631        let path = find_path_unified_bfs(&snapshot, &starts, &targets, &filter);
4632        assert!(path.is_none(), "No path should exist in an empty graph");
4633    }
4634
4635    // ==========================================================================
4636    // build_graph_load_config tests
4637    // ==========================================================================
4638
4639    crate::large_stack_test! {
4640    #[test]
4641    fn test_build_graph_load_config_defaults() {
4642        use clap::Parser as _;
4643        let cli = crate::args::Cli::parse_from(["sqry"]);
4644        let config = build_graph_load_config(&cli);
4645
4646        assert!(!config.include_hidden);
4647        assert!(!config.follow_symlinks);
4648        // Default max_depth is 32 (non-zero) → maps to Some(32)
4649        assert_eq!(config.max_depth, Some(32));
4650        assert!(!config.force_build);
4651    }
4652    }
4653
4654    crate::large_stack_test! {
4655    #[test]
4656    fn test_build_graph_load_config_hidden_flag() {
4657        use clap::Parser as _;
4658        let cli = crate::args::Cli::parse_from(["sqry", "--hidden"]);
4659        let config = build_graph_load_config(&cli);
4660        assert!(config.include_hidden);
4661    }
4662    }
4663
4664    crate::large_stack_test! {
4665    #[test]
4666    fn test_build_graph_load_config_max_depth_nonzero() {
4667        use clap::Parser as _;
4668        let cli = crate::args::Cli::parse_from(["sqry", "--max-depth", "5"]);
4669        let config = build_graph_load_config(&cli);
4670        assert_eq!(config.max_depth, Some(5));
4671    }
4672    }
4673
4674    crate::large_stack_test! {
4675    #[test]
4676    fn test_build_graph_load_config_follow_symlinks() {
4677        use clap::Parser as _;
4678        let cli = crate::args::Cli::parse_from(["sqry", "--follow"]);
4679        let config = build_graph_load_config(&cli);
4680        assert!(config.follow_symlinks);
4681    }
4682    }
4683
4684    // ==========================================================================
4685    // LanguageFilterStrategy tests
4686    // ==========================================================================
4687
4688    #[test]
4689    fn test_language_filter_strategy_empty_filter_allows_all() {
4690        // An empty language filter must allow every node regardless of language.
4691        use sqry_core::graph::unified::TraversalStrategy;
4692        use sqry_core::graph::unified::concurrent::CodeGraph;
4693        use sqry_core::graph::unified::edge::EdgeKind;
4694        use sqry_core::graph::unified::node::NodeId;
4695
4696        let graph = CodeGraph::new();
4697        let snapshot = graph.snapshot();
4698        let filter: HashSet<Language> = HashSet::new();
4699
4700        let mut strategy = LanguageFilterStrategy {
4701            snapshot: &snapshot,
4702            language_filter: &filter,
4703        };
4704
4705        let node = NodeId::new(0, 0);
4706        let from = NodeId::new(1, 0);
4707        let edge = EdgeKind::Calls {
4708            argument_count: 0,
4709            is_async: false,
4710        };
4711        assert!(
4712            strategy.should_enqueue(node, from, &edge, 1),
4713            "Empty language filter must vacuously match any node"
4714        );
4715    }
4716}