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