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