Skip to main content

sqry_cli/commands/
query.rs

1// RKG: CODE:SQRY-CLI implements REQ:SQRY-RUBY-QUALIFIED-CALLERS
2//! Query command implementation
3
4use crate::args::Cli;
5use crate::index_discovery::{augment_query_with_scope, find_nearest_index};
6use crate::output::{
7    DisplaySymbol, OutputStreams, call_identity_from_qualified_name, create_formatter,
8};
9use crate::plugin_defaults::{self, PluginSelectionMode};
10use anyhow::{Context, Result, bail};
11use sqry_core::graph::{
12    AcquisitionOperation, AutoBuildHook, FilesystemGraphProvider, GraphAcquirer, GraphAcquisition,
13    GraphAcquisitionError, GraphAcquisitionRequest, MissingGraphPolicy, PathPolicy,
14    PluginSelectionPolicy, PluginSelectionStatus, StalePolicy,
15};
16use sqry_core::query::QueryExecutor;
17use sqry_core::query::parser_new::Parser as QueryParser;
18use sqry_core::query::results::QueryResults;
19use sqry_core::query::security::QuerySecurityConfig;
20use sqry_core::query::types::{Expr, Value};
21use sqry_core::query::validator::ValidationOptions;
22use sqry_core::relations::CallIdentityMetadata;
23use sqry_core::search::Match as TextMatch;
24use sqry_core::search::classifier::{QueryClassifier, QueryType};
25use sqry_core::search::fallback::{FallbackConfig, FallbackSearchEngine, SearchResults};
26use sqry_core::session::{SessionManager, SessionStats};
27use std::env;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant};
31
32static QUERY_SESSION: std::sync::LazyLock<Mutex<Option<SessionManager>>> =
33    std::sync::LazyLock::new(|| Mutex::new(None));
34
35const DEFAULT_QUERY_LIMIT: usize = 1000;
36
37/// Simple query statistics for CLI display (replaces `sqry_core::query::QueryStats`).
38#[derive(Debug, Clone, Default)]
39struct SimpleQueryStats {
40    /// Whether a graph/index was used
41    used_index: bool,
42}
43
44/// Convert `QueryResults` to `Vec<DisplaySymbol>` for display purposes.
45///
46/// This creates `DisplaySymbol` structs directly from `QueryMatch`,
47/// avoiding the deprecated Symbol intermediate type.
48fn query_results_to_display_symbols(results: &QueryResults) -> Vec<DisplaySymbol> {
49    results
50        .iter()
51        .map(|m| DisplaySymbol::from_query_match(&m))
52        .collect()
53}
54
55struct QueryExecution {
56    stats: SimpleQueryStats,
57    symbols: Vec<DisplaySymbol>,
58    executor: Option<QueryExecutor>,
59}
60
61enum QueryExecutionOutcome {
62    Terminal,
63    Continue(Box<QueryExecution>),
64}
65
66struct NonSessionQueryParams<'a> {
67    cli: &'a Cli,
68    query_string: &'a str,
69    search_path: &'a str,
70    validation_options: ValidationOptions,
71    verbose: bool,
72    no_parallel: bool,
73    relation_context: &'a RelationDisplayContext,
74    variables: Option<&'a std::collections::HashMap<String, String>>,
75}
76
77struct QueryExecutionParams<'a> {
78    cli: &'a Cli,
79    query_string: &'a str,
80    search_path: &'a Path,
81    validation_options: ValidationOptions,
82    no_parallel: bool,
83    start: Instant,
84    query_type: QueryType,
85    variables: Option<&'a std::collections::HashMap<String, String>>,
86    /// Provider-acquired graph for the canonical workspace. Threaded into
87    /// the semantic execution path so the executor uses
88    /// `execute_on_preloaded_graph` and avoids a redundant disk load.
89    acquisition: &'a GraphAcquisition,
90}
91
92struct QueryRenderParams<'a> {
93    cli: &'a Cli,
94    query_string: &'a str,
95    verbose: bool,
96    start: Instant,
97    relation_context: &'a RelationDisplayContext,
98    index_info: IndexDiagnosticInfo,
99}
100
101struct HybridQueryParams<'a> {
102    cli: &'a Cli,
103    query_string: &'a str,
104    search_path: &'a Path,
105    validation_options: ValidationOptions,
106    no_parallel: bool,
107    start: Instant,
108    query_type: QueryType,
109    variables: Option<&'a std::collections::HashMap<String, String>>,
110    /// Provider-acquired graph. SGA03 Major #1 (codex iter2): the CLI
111    /// hybrid path threads this `Arc<CodeGraph>` directly into
112    /// [`FallbackSearchEngine::search_with_preloaded_graph`] (and
113    /// siblings) so the semantic attempt runs through
114    /// [`QueryExecutor::execute_on_preloaded_graph`] instead of the
115    /// executor's `execute_on_graph` cache+disk-load path.
116    acquisition: &'a GraphAcquisition,
117}
118
119/// Run a query command to search for symbols using AST-aware predicates
120///
121/// # Arguments
122///
123/// * `cli` - CLI arguments
124/// * `query_string` - Query string with predicates (e.g., "kind:function AND name~=/test/")
125/// * `search_path` - Path to search (file or directory)
126/// * `explain` - If true, explain the query instead of executing it
127/// * `verbose` - If true, show verbose output including cache statistics
128/// * `session_mode` - If true, use persistent session for repeated queries
129/// * `no_parallel` - If true, disable parallel query execution (for A/B testing)
130/// * `timeout_secs` - Query timeout in seconds (max 30s per security policy)
131/// * `result_limit` - Maximum number of results to return
132///
133/// # Errors
134/// Returns an error if query validation fails, execution fails, or output cannot be written.
135///
136/// # STEP_8 precedence
137///
138/// `search_path` must be resolved by the caller via
139/// [`crate::args::Cli::resolve_subcommand_path`] so that an explicit positional
140/// `<path>` argument wins over the global `--workspace` /
141/// `SQRY_WORKSPACE_FILE` flag.
142#[allow(clippy::too_many_arguments)]
143#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
144pub fn run_query(
145    cli: &Cli,
146    query_string: &str,
147    search_path: &str,
148    explain: bool,
149    verbose: bool,
150    session_mode: bool,
151    no_parallel: bool,
152    timeout_secs: Option<u64>,
153    result_limit: Option<usize>,
154    variables: &[String],
155) -> Result<()> {
156    // Create output streams with optional pager support
157    let mut streams = OutputStreams::with_pager(cli.pager_config());
158
159    ensure_repo_predicate_not_present(query_string)?;
160
161    let validation_options = build_validation_options(cli);
162
163    // Build security config from CLI flags (30s ceiling is enforced by QuerySecurityConfig)
164    let security_config = build_security_config(timeout_secs, result_limit);
165    maybe_emit_security_diagnostics(&mut streams, &security_config, verbose)?;
166
167    // NOTE: Security enforcement via QueryGuard will be integrated into QueryExecutor
168    // in a future enhancement. For now, the config is built and validated.
169    let _ = &security_config; // Silence unused warning until full integration
170
171    // Parse --var KEY=VALUE pairs into a variables map for the executor
172    let parsed_variables = parse_variable_args(variables)?;
173    let variables_opt = if parsed_variables.is_empty() {
174        None
175    } else {
176        Some(&parsed_variables)
177    };
178
179    // SGA03 Major #3 fix — strict invalid-path validation must run before
180    // pipeline/join dispatch so a malformed path produces an `invalid path`
181    // diagnostic (matching the semantic path) instead of "no pipeline matched"
182    // or a downstream executor error. Pipeline/join queries today reuse the
183    // executor's own graph cache via `execute_join` / `execute_on_graph_with_variables`;
184    // running path validation up-front gives them the same path-policy
185    // semantics as `acquire_graph_for_cli` without needing a full provider
186    // acquisition (the executor's cache load remains the canonical graph
187    // source for those code paths).
188    //
189    // Explain mode is path-independent (it operates on the query string only),
190    // so it deliberately skips this check.
191    if !explain {
192        validate_query_path_strict(Path::new(search_path))?;
193    }
194
195    // Check for pipeline queries (base query | stage)
196    if let Some(pipeline) = detect_pipeline_query(query_string)? {
197        run_pipeline_query(
198            cli,
199            &mut streams,
200            query_string,
201            search_path,
202            &pipeline,
203            no_parallel,
204            variables_opt,
205        )?;
206        return streams.finish_checked();
207    }
208
209    // Check for join queries (LHS CALLS RHS)
210    if is_join_query(query_string) {
211        run_join_query(
212            cli,
213            &mut streams,
214            query_string,
215            search_path,
216            no_parallel,
217            variables_opt,
218        )?;
219        return streams.finish_checked();
220    }
221
222    // If explain mode, use get_query_plan for detailed output (semantic only)
223    if explain {
224        run_query_explain(query_string, validation_options, no_parallel, &mut streams)?;
225        return streams.finish_checked();
226    }
227
228    let relation_context = RelationDisplayContext::from_query(query_string);
229
230    // IMPORTANT: Check session mode FIRST, before any index loading
231    // This allows session queries to short-circuit directly to the cached executor
232    // (fixes CODEX MEDIUM-2: session mode was validating before checking cache)
233    // RKG: CODE:SQRY-CLI implements REQ:SQRY-RUBY-QUALIFIED-CALLERS (MEDIUM-2 fix)
234    if session_mode {
235        let result = run_query_with_session(
236            cli,
237            &mut streams,
238            query_string,
239            search_path,
240            verbose,
241            no_parallel,
242            &relation_context,
243        );
244        // Check result first, then finalize pager
245        // If the query failed, return that error; otherwise check pager status
246        result?;
247        return streams.finish_checked();
248    }
249
250    let params = NonSessionQueryParams {
251        cli,
252        query_string,
253        search_path,
254        validation_options,
255        verbose,
256        no_parallel,
257        relation_context: &relation_context,
258        variables: variables_opt,
259    };
260    run_query_non_session(&mut streams, &params)?;
261
262    // Finalize pager (flushes buffer, waits for pager if spawned, propagates exit code)
263    streams.finish_checked()
264}
265
266fn build_validation_options(cli: &Cli) -> ValidationOptions {
267    ValidationOptions {
268        fuzzy_fields: cli.fuzzy_fields,
269        fuzzy_field_distance: cli.fuzzy_field_distance,
270    }
271}
272
273fn build_security_config(
274    timeout_secs: Option<u64>,
275    result_limit: Option<usize>,
276) -> QuerySecurityConfig {
277    let mut config = QuerySecurityConfig::default();
278    if let Some(secs) = timeout_secs {
279        config = config.with_timeout(Duration::from_secs(secs));
280    }
281    if let Some(limit) = result_limit {
282        config = config.with_result_cap(limit);
283    }
284    config
285}
286
287fn maybe_emit_security_diagnostics(
288    streams: &mut OutputStreams,
289    security_config: &QuerySecurityConfig,
290    verbose: bool,
291) -> Result<()> {
292    if verbose {
293        streams.write_diagnostic(&format!(
294            "[Security] timeout={}s, limit={}, memory={}MB",
295            security_config.timeout().as_secs(),
296            security_config.result_cap(),
297            security_config.memory_limit() / (1024 * 1024),
298        ))?;
299    }
300    Ok(())
301}
302
303fn run_query_explain(
304    query_string: &str,
305    validation_options: ValidationOptions,
306    no_parallel: bool,
307    streams: &mut OutputStreams,
308) -> Result<()> {
309    let mut executor = create_executor_with_plugins().with_validation_options(validation_options);
310    if no_parallel {
311        executor = executor.without_parallel();
312    }
313    let plan = executor.get_query_plan(query_string)?;
314    let explain_output = format!(
315        "Query Plan:\n  Original: {}\n  Optimized: {}\n\nExecution:\n{}\n\nPerformance:\n  Execution time: {}ms\n  Index-aware: {}\n  Cache: {}",
316        plan.original_query,
317        plan.optimized_query,
318        format_execution_steps(&plan.steps),
319        plan.execution_time_ms,
320        if plan.used_index { "Yes" } else { "No" },
321        format_cache_status(&plan.cache_status),
322    );
323    streams.write_diagnostic(&explain_output)?;
324    Ok(())
325}
326
327/// Resolved effective index root, augmented query, and diagnostic info.
328struct EffectiveIndexResolution {
329    index_root: PathBuf,
330    query: String,
331    info: IndexDiagnosticInfo,
332}
333
334/// Walk up the directory tree to find the nearest index, determine the effective
335/// index root, augment the query with scope filters if needed, and build diagnostic info.
336fn resolve_effective_index_root(
337    search_path: &Path,
338    query_string: &str,
339) -> EffectiveIndexResolution {
340    let index_location = find_nearest_index(search_path);
341
342    if let Some(ref loc) = index_location {
343        let root = loc.index_root.clone();
344        let (query, filtered_to) = if loc.requires_scope_filter {
345            if let Some(relative_scope) = loc.relative_scope() {
346                let scope_str = if loc.is_file_query {
347                    relative_scope.to_string_lossy().into_owned()
348                } else {
349                    format!("{}/**", relative_scope.display())
350                };
351                let augmented =
352                    augment_query_with_scope(query_string, &relative_scope, loc.is_file_query);
353                (augmented, Some(scope_str))
354            } else {
355                (query_string.to_string(), None)
356            }
357        } else {
358            (query_string.to_string(), None)
359        };
360        let info = IndexDiagnosticInfo {
361            index_root: Some(root.clone()),
362            filtered_to,
363            used_ancestor_index: loc.is_ancestor,
364        };
365        EffectiveIndexResolution {
366            index_root: root,
367            query,
368            info,
369        }
370    } else {
371        EffectiveIndexResolution {
372            index_root: search_path.to_path_buf(),
373            query: query_string.to_string(),
374            info: IndexDiagnosticInfo::default(),
375        }
376    }
377}
378
379fn run_query_non_session(
380    streams: &mut OutputStreams,
381    params: &NonSessionQueryParams<'_>,
382) -> Result<()> {
383    let NonSessionQueryParams {
384        cli,
385        query_string,
386        search_path,
387        validation_options,
388        verbose,
389        no_parallel,
390        relation_context,
391        variables,
392    } = *params;
393    let search_path_path = Path::new(search_path);
394
395    // SGA03 Major #4 — `--text` mode is pure text scanning and must
396    // continue to work on unindexed paths. Skip full graph acquisition
397    // here; the strict path-validation step already ran in `run_query`
398    // before pipeline/join detection, so invalid paths are rejected
399    // up-front in this mode too.
400    if cli.text {
401        return run_query_text_only(streams, params);
402    }
403
404    // SGA03 regression fix — validate query syntax BEFORE full graph
405    // acquisition so that an invalid query string (parse error or
406    // unknown field) is reported as a parse / validation error
407    // (exit 2) rather than being masked by `acquire_graph_for_cli`'s
408    // "no graph found" path-acquisition error (exit 1) when the
409    // search path is a valid but unindexed directory.
410    //
411    // CLI_INTEGRATION.md §4 Exit behavior: invalid query syntax remains
412    // a query-parse failure, not an acquisition failure. Path validation
413    // (in `run_query`) still fires before this probe, so an invalid path
414    // wins over an invalid query — matching the existing precedence
415    // tested in `cli_invalid_path_rejected_before_graph_load`.
416    //
417    // The probe is gated on `QueryClassifier::classify(query) == Semantic`
418    // so it only fires for queries that pre-SGA03 would have produced an
419    // exit-2 parse error anyway. Hybrid- and Text-classified queries keep
420    // their forgiving fallback semantics (e.g. `unknown_field:value`
421    // falling back to a text search at exit 0) — the parse probe must
422    // not regress that behavior.
423    if QueryClassifier::classify(query_string) == QueryType::Semantic {
424        probe_validate_query_syntax(cli, search_path_path, query_string, validation_options)?;
425    }
426
427    // SGA03: route the read-only graph acquisition through
428    // `FilesystemGraphProvider`. The provider canonicalizes the path,
429    // enforces strict path policy (existence + workspace boundary +
430    // symlink-escape rejection), and verifies snapshot integrity *before*
431    // any query work runs. Path errors here precede the index-ancestor
432    // discovery diagnostic, satisfying the SGA03 acceptance criterion that
433    // invalid paths fail before graph load.
434    let acquisition = acquire_graph_for_cli(cli, search_path_path)?;
435
436    // Index ancestor discovery: find nearest .sqry-index in directory tree.
437    // The provider already validated the path; this call only computes the
438    // CLI-specific scope filter (`(query) AND path:...`).
439    let resolution = resolve_effective_index_root(search_path_path, query_string);
440    let EffectiveIndexResolution {
441        index_root: effective_index_root,
442        query: effective_query,
443        info: index_info,
444    } = resolution;
445
446    let query_type = QueryClassifier::classify(&effective_query);
447
448    let start = Instant::now();
449    let execution_params = QueryExecutionParams {
450        cli,
451        query_string: &effective_query,
452        search_path: &effective_index_root,
453        validation_options,
454        no_parallel,
455        start,
456        query_type,
457        variables,
458        acquisition: &acquisition,
459    };
460    let outcome = execute_query_mode(streams, &execution_params)?;
461    let render_params = QueryRenderParams {
462        cli,
463        query_string: &effective_query,
464        verbose,
465        start,
466        relation_context,
467        index_info,
468    };
469    render_query_outcome(streams, outcome, render_params)
470}
471
472fn execute_query_mode(
473    streams: &mut OutputStreams,
474    params: &QueryExecutionParams<'_>,
475) -> Result<QueryExecutionOutcome> {
476    let cli = params.cli;
477    let query_string = params.query_string;
478    let search_path = params.search_path;
479    let validation_options = params.validation_options;
480    let no_parallel = params.no_parallel;
481    let start = params.start;
482    let query_type = params.query_type;
483    let variables = params.variables;
484    let acquisition = params.acquisition;
485
486    if should_use_hybrid_search(cli) {
487        let params = HybridQueryParams {
488            cli,
489            query_string,
490            search_path,
491            validation_options,
492            no_parallel,
493            start,
494            query_type,
495            variables,
496            acquisition,
497        };
498        execute_hybrid_query(streams, &params)
499    } else {
500        execute_semantic_query(
501            cli,
502            query_string,
503            search_path,
504            validation_options,
505            no_parallel,
506            variables,
507            acquisition,
508        )
509    }
510}
511
512fn render_query_outcome(
513    streams: &mut OutputStreams,
514    outcome: QueryExecutionOutcome,
515    params: QueryRenderParams<'_>,
516) -> Result<()> {
517    let QueryRenderParams {
518        cli,
519        query_string,
520        verbose,
521        start,
522        relation_context,
523        index_info,
524    } = params;
525    if let QueryExecutionOutcome::Continue(mut execution) = outcome {
526        let elapsed = start.elapsed();
527        let execution = &mut *execution;
528        let diagnostics = QueryDiagnostics::Standard { index_info };
529        render_semantic_results(
530            cli,
531            streams,
532            query_string,
533            &mut execution.symbols,
534            &execution.stats,
535            elapsed,
536            verbose,
537            execution.executor.as_ref(),
538            &diagnostics,
539            relation_context,
540        )?;
541    }
542
543    Ok(())
544}
545
546/// SGA03 Major #4 — execute the `--text` (text-only) mode without
547/// acquiring a graph through `FilesystemGraphProvider`.
548///
549/// Text-only search is intentionally unindexed: it ripgrep-scans files
550/// under `search_path` and never consults the graph. Routing it through
551/// `acquire_graph_for_cli` would have made `sqry query --text` fail on
552/// any directory that has not been indexed yet, which is a regression
553/// versus pre-migration behavior.
554///
555/// Strict path validation still ran in `run_query` before we got here,
556/// so non-existent / non-canonicalizable paths have already been
557/// rejected; this function can assume the path is valid.
558fn run_query_text_only(
559    streams: &mut OutputStreams,
560    params: &NonSessionQueryParams<'_>,
561) -> Result<()> {
562    let NonSessionQueryParams {
563        cli,
564        query_string,
565        search_path,
566        ..
567    } = *params;
568    let search_path_path = Path::new(search_path);
569
570    // SGA03 Major #4 (codex iter3): pure `--text` search must not touch the
571    // persisted graph manifest or the plugin selection it implies. Building
572    // an executor through `create_executor_with_plugins_for_cli` would call
573    // `resolve_plugin_selection(.., PluginSelectionMode::ReadOnly)`, which
574    // for any existing `.sqry/graph` resolves the manifest's
575    // `active_plugin_ids` and fails when the persisted ids no longer match
576    // the running binary's registry — even though text mode is a ripgrep
577    // scan that never consults the graph or any plugin field.
578    //
579    // `FallbackSearchEngine::with_config` constructs a default
580    // `QueryExecutor` (no plugin manager, no manifest read), and
581    // `search_text_only` only exercises the text searcher, so neither the
582    // discarded `validation_options` nor `no_parallel` hooks are observable
583    // here. Strict path validation already ran in `run_query` before
584    // dispatching, satisfying the SGA03 invalid-path tightening.
585    let config = build_hybrid_config(cli);
586    let mut engine = FallbackSearchEngine::with_config(config)?;
587
588    let start = Instant::now();
589    let results = engine.search_text_only(query_string, search_path_path)?;
590    let elapsed = start.elapsed();
591
592    match results {
593        SearchResults::Text { matches, .. } => {
594            render_text_results(cli, streams, &matches, elapsed)?;
595        }
596        SearchResults::Semantic { results, .. } => {
597            // `search_text_only` is supposed to return `Text`, but be
598            // defensive — render any semantic results through the standard
599            // formatter so we never silently drop matches.
600            let mut symbols = query_results_to_display_symbols(&results);
601            let stats = SimpleQueryStats { used_index: false };
602            let diagnostics = QueryDiagnostics::Standard {
603                index_info: IndexDiagnosticInfo::default(),
604            };
605            render_semantic_results(
606                cli,
607                streams,
608                query_string,
609                &mut symbols,
610                &stats,
611                elapsed,
612                params.verbose,
613                None,
614                &diagnostics,
615                params.relation_context,
616            )?;
617        }
618    }
619    Ok(())
620}
621
622fn execute_hybrid_query(
623    streams: &mut OutputStreams,
624    params: &HybridQueryParams<'_>,
625) -> Result<QueryExecutionOutcome> {
626    let cli = params.cli;
627    let query_string = params.query_string;
628    let search_path = params.search_path;
629    let validation_options = params.validation_options;
630    let no_parallel = params.no_parallel;
631    let start = params.start;
632    let query_type = params.query_type;
633    let variables = params.variables;
634    let acquisition = params.acquisition;
635
636    // Resolve variables in the query string for hybrid search.
637    // FallbackSearchEngine doesn't support variable threading, so we resolve
638    // at the AST level and serialize back to a query string before passing it.
639    let effective_query = if let Some(vars) = variables {
640        let ast = QueryParser::parse_query(query_string)
641            .map_err(|e| anyhow::anyhow!("Failed to parse query for variable resolution: {e}"))?;
642        let resolved = sqry_core::query::types::resolve_variables(&ast.root, vars)
643            .map_err(|e| anyhow::anyhow!("{e}"))?;
644        let resolved_ast = sqry_core::query::types::Query {
645            root: resolved,
646            span: ast.span,
647        };
648        std::borrow::Cow::Owned(sqry_core::query::parsed_query::serialize_query(
649            &resolved_ast,
650        ))
651    } else {
652        std::borrow::Cow::Borrowed(query_string)
653    };
654
655    // Use hybrid search engine with plugin-enabled executor
656    // This allows metadata queries like async:true and visibility:public to work
657    let config = build_hybrid_config(cli);
658    let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
659        .with_validation_options(validation_options);
660    if no_parallel {
661        executor = executor.without_parallel();
662    }
663    let mut engine = FallbackSearchEngine::with_config_and_executor(config.clone(), executor)?;
664
665    emit_search_mode_diagnostic(cli, streams, query_type, &config)?;
666
667    let results = run_hybrid_search(cli, &mut engine, &effective_query, search_path, acquisition)?;
668    let elapsed = start.elapsed();
669
670    match results {
671        SearchResults::Semantic { results, .. } => {
672            let symbols = query_results_to_display_symbols(&results);
673            Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
674                stats: build_query_stats(true, symbols.len()),
675                symbols,
676                executor: None,
677            })))
678        }
679        SearchResults::Text { matches, .. } => {
680            render_text_results(cli, streams, &matches, elapsed)?;
681            Ok(QueryExecutionOutcome::Terminal)
682        }
683    }
684}
685
686fn execute_semantic_query(
687    cli: &Cli,
688    query_string: &str,
689    search_path: &Path,
690    validation_options: ValidationOptions,
691    no_parallel: bool,
692    variables: Option<&std::collections::HashMap<String, String>>,
693    acquisition: &GraphAcquisition,
694) -> Result<QueryExecutionOutcome> {
695    let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
696        .with_validation_options(validation_options);
697    if no_parallel {
698        executor = executor.without_parallel();
699    }
700    // SGA03: execute on the provider-acquired graph rather than re-loading
701    // through `execute_on_graph_with_variables`. The provider already
702    // canonicalized the workspace and verified snapshot integrity; using
703    // `execute_on_preloaded_graph` avoids a redundant disk load and keeps
704    // the executor's process-wide graph_cache untouched (the same contract
705    // daemon callers rely on).
706    let query_results = executor.execute_on_preloaded_graph(
707        Arc::clone(&acquisition.graph),
708        query_string,
709        &acquisition.workspace_root,
710        variables,
711    )?;
712    let symbols = query_results_to_display_symbols(&query_results);
713    let stats = SimpleQueryStats { used_index: true };
714    Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
715        stats,
716        symbols,
717        executor: Some(executor),
718    })))
719}
720
721fn emit_search_mode_diagnostic(
722    cli: &Cli,
723    streams: &mut OutputStreams,
724    query_type: QueryType,
725    config: &FallbackConfig,
726) -> Result<()> {
727    if !config.show_search_mode || cli.json {
728        return Ok(());
729    }
730
731    let message = match query_type {
732        QueryType::Semantic => "[Semantic search mode]",
733        QueryType::Text => "[Text search mode]",
734        QueryType::Hybrid => "[Hybrid mode: trying semantic first...]",
735    };
736    streams.write_diagnostic(message)?;
737    Ok(())
738}
739
740fn run_hybrid_search(
741    cli: &Cli,
742    engine: &mut FallbackSearchEngine,
743    query_string: &str,
744    search_path: &Path,
745    acquisition: &GraphAcquisition,
746) -> Result<SearchResults> {
747    if cli.text {
748        // Force text-only search — graph is unused on this branch.
749        engine.search_text_only(query_string, search_path)
750    } else if cli.semantic {
751        // Force semantic-only search against the provider-acquired graph.
752        // SGA03 Major #1 (codex iter2): `search_semantic_only` re-enters
753        // the executor's cache+disk-load path; the preloaded variant
754        // forwards the acquired `Arc<CodeGraph>` straight to
755        // `execute_on_preloaded_graph`.
756        engine.search_semantic_only_with_preloaded_graph(
757            query_string,
758            Arc::clone(&acquisition.graph),
759            search_path,
760        )
761    } else {
762        // Automatic hybrid search with fallback — same provider-acquired
763        // graph is reused for both the semantic attempt and any text
764        // fallback that follows.
765        engine.search_with_preloaded_graph(
766            query_string,
767            Arc::clone(&acquisition.graph),
768            search_path,
769        )
770    }
771}
772
773fn build_query_stats(used_index: bool, _symbol_count: usize) -> SimpleQueryStats {
774    SimpleQueryStats { used_index }
775}
776
777fn render_text_results(
778    cli: &Cli,
779    streams: &mut OutputStreams,
780    matches: &[TextMatch],
781    elapsed: Duration,
782) -> Result<()> {
783    if cli.json {
784        // JSON mode: serialize text matches directly
785        let json_output = serde_json::json!({
786            "text_matches": matches,
787            "match_count": matches.len(),
788            "execution_time_ms": elapsed.as_millis(),
789        });
790        streams.write_result(&serde_json::to_string_pretty(&json_output)?)?;
791    } else if cli.count {
792        // Count mode: just show the count
793        streams.write_result(&matches.len().to_string())?;
794    } else {
795        // Normal mode: print matches in grep format
796        for m in matches {
797            streams.write_result(&format!(
798                "{}:{}:{}",
799                m.path.display(),
800                m.line,
801                m.line_text.trim()
802            ))?;
803        }
804
805        // Show performance info to stderr (not in JSON or count mode)
806        streams.write_diagnostic(&format!(
807            "\nQuery executed ({}ms) - {} text matches found",
808            elapsed.as_millis(),
809            matches.len()
810        ))?;
811    }
812
813    Ok(())
814}
815
816// RKG: CODE:SQRY-CLI implements REQ:SQRY-RUBY-QUALIFIED-CALLERS (MEDIUM-2 fix)
817fn run_query_with_session(
818    cli: &Cli,
819    streams: &mut OutputStreams,
820    query_string: &str,
821    search_path: &str,
822    verbose: bool,
823    _no_parallel: bool,
824    relation_ctx: &RelationDisplayContext,
825) -> Result<()> {
826    if cli.text {
827        bail!("--session is only available for semantic queries (remove --text)");
828    }
829
830    let search_path_path = Path::new(search_path);
831
832    // SGA03 regression fix — validate query syntax BEFORE full graph
833    // acquisition so that invalid syntax / unknown fields surface as
834    // parse / validation errors (exit 2) rather than being masked by
835    // a "no graph found" acquisition error (exit 1) when the path is
836    // valid but unindexed. Mirrors the probe in `run_query_non_session`,
837    // including the `Semantic`-only classification gate that preserves
838    // the forgiving Hybrid/Text fallback behavior pinned by
839    // `tests/exit_codes.rs`.
840    if QueryClassifier::classify(query_string) == QueryType::Semantic {
841        probe_validate_query_syntax(
842            cli,
843            search_path_path,
844            query_string,
845            build_validation_options(cli),
846        )?;
847    }
848
849    // SGA03: enforce strict path policy via the shared provider before any
850    // session work runs. Session mode keeps its own warm graph cache, so the
851    // acquired graph is dropped immediately after path validation — the
852    // provider's role here is purely to fail invalid paths before the
853    // session manager loads anything.
854    //
855    // Session mode pins `MissingGraphPolicy::Error` (no auto-build hook) so
856    // the provider runs path-policy validation but does not invoke the CLI
857    // auto-build path used by non-session queries. A typed `NoGraph`
858    // outcome here is intentionally swallowed so the session-specific
859    // "no index found" diagnostic produced by `resolve_session_index` below
860    // stays the canonical user-facing message — that contract is pinned by
861    // `tests/integration_tests.rs::test_query_session_requires_index`.
862    // Any other acquisition error (invalid path, incompatible graph, load
863    // failure) propagates so SGA03's strict path-policy semantics remain
864    // in force.
865    match acquire_graph_for_cli_typed(cli, search_path_path, MissingGraphPolicy::Error)? {
866        Ok(_acquisition) => {}
867        Err(GraphAcquisitionError::NoGraph { .. }) => {
868            // Fall through: `resolve_session_index` below produces the
869            // canonical "no index found" diagnostic.
870        }
871        Err(other) => return Err(map_acquisition_error(other)),
872    }
873
874    // Index ancestor discovery for session mode
875    let (workspace, relative_scope, is_file_query, is_ancestor) =
876        resolve_session_index(search_path_path)?;
877
878    // Build index diagnostic info (for ancestor index or file queries)
879    let index_info = if is_ancestor || relative_scope.is_some() {
880        // Build filtered_to with proper format (file vs directory)
881        let filtered_to = relative_scope.as_ref().map(|p| {
882            if is_file_query {
883                p.to_string_lossy().into_owned()
884            } else {
885                format!("{}/**", p.display())
886            }
887        });
888        IndexDiagnosticInfo {
889            index_root: Some(workspace.clone()),
890            filtered_to,
891            used_ancestor_index: is_ancestor,
892        }
893    } else {
894        IndexDiagnosticInfo::default()
895    };
896
897    // Augment query with scope filter if using ancestor index
898    let effective_query: std::borrow::Cow<'_, str> = if let Some(ref scope) = relative_scope {
899        std::borrow::Cow::Owned(augment_query_with_scope(query_string, scope, is_file_query))
900    } else {
901        std::borrow::Cow::Borrowed(query_string)
902    };
903
904    // Check session cache first before expensive validation
905    // (fixes CODEX MEDIUM-2: avoid validation on warm queries)
906    let mut guard = QUERY_SESSION
907        .lock()
908        .expect("global session cache mutex poisoned");
909
910    if guard.is_none() {
911        // Cold start: create session (graph will be loaded on first query)
912        let config = sqry_core::session::SessionConfig::default();
913        *guard = Some(
914            SessionManager::with_config(config).context("failed to initialise session manager")?,
915        );
916    }
917
918    let session = guard.as_ref().expect("session manager must be initialised");
919    let before = session.stats();
920    let start = Instant::now();
921    let query_results = session
922        .query(&workspace, &effective_query)
923        .with_context(|| format!("failed to execute query \"{}\"", &effective_query))?;
924    let elapsed = start.elapsed();
925    let after = session.stats();
926    let cache_hit = after.cache_hits > before.cache_hits;
927
928    let mut symbols = query_results_to_display_symbols(&query_results);
929
930    let stats = SimpleQueryStats { used_index: true };
931
932    let diagnostics = QueryDiagnostics::Session {
933        cache_hit,
934        stats: after,
935        index_info,
936    };
937    render_semantic_results(
938        cli,
939        streams,
940        &effective_query,
941        &mut symbols,
942        &stats,
943        elapsed,
944        verbose,
945        None,
946        &diagnostics,
947        relation_ctx,
948    )
949}
950
951/// Resolve index location for session mode, walking up directory tree if needed.
952///
953/// Returns `(index_root, relative_scope, is_file_query, is_ancestor)` for query augmentation.
954/// For session mode, file paths are not supported (must be directory).
955fn resolve_session_index(path: &Path) -> Result<(PathBuf, Option<PathBuf>, bool, bool)> {
956    if !path.exists() {
957        bail!(
958            "session mode requires a directory ({} does not exist)",
959            path.display()
960        );
961    }
962
963    // Session mode requires a directory, not a file
964    if path.is_file() {
965        bail!(
966            "session mode requires a directory path ({} is a file). \
967             For file-specific queries, omit --session.",
968            path.display()
969        );
970    }
971
972    // Use index discovery to find nearest .sqry-index
973    if let Some(loc) = find_nearest_index(path) {
974        let relative_scope = if loc.requires_scope_filter {
975            loc.relative_scope()
976        } else {
977            None
978        };
979        Ok((
980            loc.index_root,
981            relative_scope,
982            loc.is_file_query,
983            loc.is_ancestor,
984        ))
985    } else {
986        bail!(
987            "no index found at {} or any parent directory. \
988             Run `sqry index <root>` first.",
989            path.display()
990        );
991    }
992}
993
994fn ensure_repo_predicate_not_present(query_string: &str) -> Result<()> {
995    if let Ok(query) = QueryParser::parse_query(query_string) {
996        if expr_has_repo_predicate(&query.root) {
997            bail!(
998                "repo: filters are only supported via `sqry workspace query` (multi-repo command)"
999            );
1000        }
1001        return Ok(());
1002    }
1003
1004    if query_string.contains("repo:") {
1005        bail!("repo: filters are only supported via `sqry workspace query` (multi-repo command)");
1006    }
1007
1008    Ok(())
1009}
1010
1011fn expr_has_repo_predicate(expr: &Expr) -> bool {
1012    match expr {
1013        Expr::And(operands) | Expr::Or(operands) => operands.iter().any(expr_has_repo_predicate),
1014        Expr::Not(operand) => expr_has_repo_predicate(operand),
1015        Expr::Condition(condition) => condition.field.as_str() == "repo",
1016        Expr::Join(join) => {
1017            expr_has_repo_predicate(&join.left) || expr_has_repo_predicate(&join.right)
1018        }
1019    }
1020}
1021
1022/// Info about which index was used and any scope filtering applied.
1023#[derive(Default)]
1024struct IndexDiagnosticInfo {
1025    /// Path to the index root directory (where .sqry-index lives)
1026    index_root: Option<PathBuf>,
1027    /// Scope filter applied (e.g., "src/**" or "main.rs")
1028    filtered_to: Option<String>,
1029    /// True if index was found in an ancestor directory
1030    used_ancestor_index: bool,
1031}
1032
1033enum QueryDiagnostics {
1034    Standard {
1035        index_info: IndexDiagnosticInfo,
1036    },
1037    Session {
1038        cache_hit: bool,
1039        stats: SessionStats,
1040        index_info: IndexDiagnosticInfo,
1041    },
1042}
1043
1044struct QueryLimitInfo {
1045    total_matches: usize,
1046    limit: usize,
1047    truncated: bool,
1048}
1049
1050#[allow(clippy::too_many_arguments)]
1051fn render_semantic_results(
1052    cli: &Cli,
1053    streams: &mut OutputStreams,
1054    query_string: &str,
1055    symbols: &mut Vec<DisplaySymbol>,
1056    stats: &SimpleQueryStats,
1057    elapsed: Duration,
1058    verbose: bool,
1059    executor_opt: Option<&QueryExecutor>,
1060    diagnostics: &QueryDiagnostics,
1061    relation_ctx: &RelationDisplayContext,
1062) -> Result<()> {
1063    // Optional sorting (opt-in)
1064    apply_sorting(cli, symbols);
1065
1066    // Apply limit if specified (default: 1000 for query command)
1067    let limit_info = apply_symbol_limit(symbols, cli.limit.unwrap_or(DEFAULT_QUERY_LIMIT));
1068
1069    // Extract index info from diagnostics for JSON output
1070    let index_info = match diagnostics {
1071        QueryDiagnostics::Standard { index_info }
1072        | QueryDiagnostics::Session { index_info, .. } => index_info,
1073    };
1074
1075    // Build metadata for structured JSON output
1076    let metadata =
1077        build_formatter_metadata(query_string, limit_info.total_matches, elapsed, index_info);
1078
1079    let identity_overrides = build_identity_overrides(cli, symbols, relation_ctx);
1080
1081    let display_symbols =
1082        build_display_symbols_with_identities(symbols, identity_overrides.as_ref());
1083
1084    // Create formatter based on CLI flags
1085    format_semantic_output(cli, streams, &display_symbols, &metadata)?;
1086
1087    maybe_emit_truncation_notice(cli, &limit_info);
1088
1089    if cli.json || cli.count {
1090        return Ok(());
1091    }
1092
1093    write_query_summary(streams, stats, elapsed, symbols.len(), diagnostics)?;
1094
1095    if verbose {
1096        emit_verbose_cache_stats(streams, stats, executor_opt, diagnostics)?;
1097    }
1098
1099    maybe_emit_debug_cache(cli, streams, executor_opt, stats)?;
1100
1101    Ok(())
1102}
1103
1104fn apply_sorting(cli: &Cli, symbols: &mut [DisplaySymbol]) {
1105    if let Some(sort_field) = cli.sort {
1106        crate::commands::sort::sort_symbols(symbols, sort_field);
1107    }
1108}
1109
1110fn apply_symbol_limit(symbols: &mut Vec<DisplaySymbol>, limit: usize) -> QueryLimitInfo {
1111    let total_matches = symbols.len();
1112    let truncated = total_matches > limit;
1113    if truncated {
1114        symbols.truncate(limit);
1115    }
1116    QueryLimitInfo {
1117        total_matches,
1118        limit,
1119        truncated,
1120    }
1121}
1122
1123fn build_formatter_metadata(
1124    query_string: &str,
1125    total_matches: usize,
1126    elapsed: Duration,
1127    index_info: &IndexDiagnosticInfo,
1128) -> crate::output::FormatterMetadata {
1129    crate::output::FormatterMetadata {
1130        pattern: Some(query_string.to_string()),
1131        total_matches,
1132        execution_time: elapsed,
1133        filters: sqry_core::json_response::Filters {
1134            kind: None,
1135            lang: None,
1136            ignore_case: false,
1137            exact: false,
1138            fuzzy: None,
1139        },
1140        index_age_seconds: None,
1141        // Include scope info when any filtering is applied (ancestor or file query)
1142        used_ancestor_index: if index_info.used_ancestor_index || index_info.filtered_to.is_some() {
1143            Some(index_info.used_ancestor_index)
1144        } else {
1145            None
1146        },
1147        filtered_to: index_info.filtered_to.clone(),
1148    }
1149}
1150
1151fn build_identity_overrides(
1152    cli: &Cli,
1153    symbols: &[DisplaySymbol],
1154    relation_ctx: &RelationDisplayContext,
1155) -> Option<DisplayIdentities> {
1156    if cli.qualified_names || cli.json {
1157        Some(compute_display_identities(symbols, relation_ctx))
1158    } else {
1159        None
1160    }
1161}
1162
1163fn format_semantic_output(
1164    cli: &Cli,
1165    streams: &mut OutputStreams,
1166    display_symbols: &[DisplaySymbol],
1167    metadata: &crate::output::FormatterMetadata,
1168) -> Result<()> {
1169    let formatter = create_formatter(cli);
1170    formatter.format(display_symbols, Some(metadata), streams)?;
1171    Ok(())
1172}
1173
1174fn maybe_emit_truncation_notice(cli: &Cli, limit_info: &QueryLimitInfo) {
1175    if !cli.json && limit_info.truncated {
1176        eprintln!(
1177            "\nShowing {} of {} matches (use --limit to adjust)",
1178            limit_info.limit, limit_info.total_matches
1179        );
1180    }
1181}
1182
1183fn build_display_symbols_with_identities(
1184    symbols: &[DisplaySymbol],
1185    identity_overrides: Option<&DisplayIdentities>,
1186) -> Vec<DisplaySymbol> {
1187    match identity_overrides {
1188        Some(identities) => symbols
1189            .iter()
1190            .enumerate()
1191            .map(|(idx, symbol)| {
1192                let invoker_identity = identities
1193                    .invoker_identities
1194                    .get(idx)
1195                    .and_then(Clone::clone);
1196                let target_identity = identities.target_identities.get(idx).and_then(Clone::clone);
1197
1198                // Use the appropriate constructor based on which identity is present
1199                if invoker_identity.is_some() {
1200                    symbol.clone().with_caller_identity(invoker_identity)
1201                } else if target_identity.is_some() {
1202                    symbol.clone().with_callee_identity(target_identity)
1203                } else {
1204                    symbol.clone()
1205                }
1206            })
1207            .collect(),
1208        None => symbols.to_vec(),
1209    }
1210}
1211
1212fn write_query_summary(
1213    streams: &mut OutputStreams,
1214    stats: &SimpleQueryStats,
1215    elapsed: Duration,
1216    symbol_count: usize,
1217    diagnostics: &QueryDiagnostics,
1218) -> Result<()> {
1219    use std::fmt::Write as _;
1220
1221    streams.write_diagnostic("")?;
1222
1223    // Extract index_info from diagnostics
1224    let index_info = match diagnostics {
1225        QueryDiagnostics::Standard { index_info }
1226        | QueryDiagnostics::Session { index_info, .. } => index_info,
1227    };
1228
1229    // Build index status message with ancestor info if applicable
1230    let index_status = if stats.used_index {
1231        if index_info.used_ancestor_index {
1232            if let Some(ref root) = index_info.index_root {
1233                format!("✓ Using index from {}", root.display())
1234            } else {
1235                "✓ Used index".to_string()
1236            }
1237        } else {
1238            "✓ Used index".to_string()
1239        }
1240    } else {
1241        "ℹ No index found".to_string()
1242    };
1243
1244    let mut msg = format!(
1245        "{} - Query executed ({}ms) - {} symbols found",
1246        index_status,
1247        elapsed.as_millis(),
1248        symbol_count
1249    );
1250
1251    // Add scope filter info if applicable (ancestor index or file query)
1252    if let Some(ref filtered_to) = index_info.filtered_to {
1253        let _ = write!(msg, " (filtered to {filtered_to})");
1254    }
1255
1256    if let QueryDiagnostics::Session { cache_hit, .. } = diagnostics {
1257        let cache_state = if *cache_hit {
1258            "session cache hit"
1259        } else {
1260            "session cache miss"
1261        };
1262        let _ = write!(msg, " [{cache_state}]");
1263    }
1264
1265    streams.write_diagnostic(&msg)?;
1266
1267    Ok(())
1268}
1269
1270fn emit_verbose_cache_stats(
1271    streams: &mut OutputStreams,
1272    _stats: &SimpleQueryStats,
1273    executor_opt: Option<&QueryExecutor>,
1274    diagnostics: &QueryDiagnostics,
1275) -> Result<()> {
1276    match (executor_opt, diagnostics) {
1277        (Some(executor), _) => emit_executor_cache_stats(streams, executor),
1278        (None, QueryDiagnostics::Session { stats, .. }) => emit_session_cache_stats(streams, stats),
1279        _ => emit_hybrid_cache_notice(streams),
1280    }
1281}
1282
1283fn emit_executor_cache_stats(streams: &mut OutputStreams, executor: &QueryExecutor) -> Result<()> {
1284    let (parse_stats, result_stats) = executor.cache_stats();
1285
1286    streams.write_diagnostic("")?;
1287    streams.write_diagnostic("Cache Statistics:")?;
1288
1289    let parse_msg = format!(
1290        "  Parse cache:  {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1291        parse_stats.hit_rate() * 100.0,
1292        parse_stats.hits,
1293        parse_stats.misses,
1294        parse_stats.evictions,
1295    );
1296    streams.write_diagnostic(&parse_msg)?;
1297
1298    let result_msg = format!(
1299        "  Result cache: {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1300        result_stats.hit_rate() * 100.0,
1301        result_stats.hits,
1302        result_stats.misses,
1303        result_stats.evictions,
1304    );
1305    streams.write_diagnostic(&result_msg)?;
1306
1307    Ok(())
1308}
1309
1310fn emit_session_cache_stats(streams: &mut OutputStreams, stats: &SessionStats) -> Result<()> {
1311    let total_cache_events = stats.cache_hits + stats.cache_misses;
1312    let hit_rate = if total_cache_events > 0 {
1313        (u64_to_f64_lossy(stats.cache_hits) / u64_to_f64_lossy(total_cache_events)) * 100.0
1314    } else {
1315        0.0
1316    };
1317
1318    streams.write_diagnostic("")?;
1319    streams.write_diagnostic("Session statistics:")?;
1320    let _ = streams.write_diagnostic(&format!("  Cached indexes : {}", stats.cached_graphs));
1321    let _ = streams.write_diagnostic(&format!("  Total queries  : {}", stats.total_queries));
1322    let _ = streams.write_diagnostic(&format!(
1323        "  Cache hits     : {} ({hit_rate:.1}% hit rate)",
1324        stats.cache_hits
1325    ));
1326    let _ = streams.write_diagnostic(&format!("  Cache misses   : {}", stats.cache_misses));
1327    let _ = streams.write_diagnostic(&format!(
1328        "  Estimated memory: ~{} MB",
1329        stats.total_memory_mb
1330    ));
1331
1332    Ok(())
1333}
1334
1335fn emit_hybrid_cache_notice(streams: &mut OutputStreams) -> Result<()> {
1336    streams.write_diagnostic("")?;
1337    streams.write_diagnostic("Cache statistics not available in hybrid search mode")?;
1338    Ok(())
1339}
1340
1341struct DisplayIdentities {
1342    invoker_identities: Vec<Option<CallIdentityMetadata>>,
1343    target_identities: Vec<Option<CallIdentityMetadata>>,
1344}
1345
1346fn compute_display_identities(
1347    symbols: &[DisplaySymbol],
1348    relation_ctx: &RelationDisplayContext,
1349) -> DisplayIdentities {
1350    // Build identity metadata from symbol qualified names for relation queries.
1351    // For callers: queries, each result is a caller and gets caller_identity.
1352    // For callees: queries, each result is a callee and gets callee_identity.
1353    let has_incoming_targets = !relation_ctx.caller_targets.is_empty();
1354    let has_outgoing_targets = !relation_ctx.callee_targets.is_empty();
1355
1356    let identities: Vec<Option<CallIdentityMetadata>> = symbols
1357        .iter()
1358        .map(build_identity_from_display_symbol)
1359        .collect();
1360
1361    if has_incoming_targets {
1362        DisplayIdentities {
1363            invoker_identities: identities,
1364            target_identities: vec![None; symbols.len()],
1365        }
1366    } else if has_outgoing_targets {
1367        DisplayIdentities {
1368            invoker_identities: vec![None; symbols.len()],
1369            target_identities: identities,
1370        }
1371    } else {
1372        DisplayIdentities {
1373            invoker_identities: vec![None; symbols.len()],
1374            target_identities: vec![None; symbols.len()],
1375        }
1376    }
1377}
1378
1379fn build_identity_from_display_symbol(symbol: &DisplaySymbol) -> Option<CallIdentityMetadata> {
1380    let language = symbol.metadata.get("__raw_language").map(String::as_str);
1381    let is_static = symbol
1382        .metadata
1383        .get("static")
1384        .is_some_and(|value| value == "true");
1385
1386    build_identity_from_qualified_name(&symbol.qualified_name, &symbol.kind, language, is_static)
1387}
1388fn build_identity_from_qualified_name(
1389    qualified: &str,
1390    kind: &str,
1391    language: Option<&str>,
1392    is_static: bool,
1393) -> Option<CallIdentityMetadata> {
1394    call_identity_from_qualified_name(qualified, kind, language, is_static)
1395}
1396
1397/// Format execution steps for display
1398fn format_execution_steps(steps: &[sqry_core::query::ExecutionStep]) -> String {
1399    steps
1400        .iter()
1401        .map(|step| {
1402            format!(
1403                "  {}. {} ({}ms)",
1404                step.step_num, step.operation, step.time_ms
1405            )
1406        })
1407        .collect::<Vec<_>>()
1408        .join("\n")
1409}
1410
1411/// Format cache status for display
1412fn format_cache_status(status: &sqry_core::query::CacheStatus) -> String {
1413    match (status.parse_cache_hit, status.result_cache_hit) {
1414        (true, true) => "HIT (100% cached)".to_string(),
1415        (true, false) => "PARTIAL HIT (query cached, results computed)".to_string(),
1416        (false, true) => "PARTIAL HIT (query parsed, results cached)".to_string(),
1417        (false, false) => "MISS (first run)".to_string(),
1418    }
1419}
1420
1421fn env_debug_cache_enabled() -> bool {
1422    matches!(
1423        env::var("SQRY_CACHE_DEBUG"),
1424        Ok(value) if value == "1" || value.eq_ignore_ascii_case("true")
1425    )
1426}
1427
1428#[derive(Default)]
1429struct RelationDisplayContext {
1430    caller_targets: Vec<String>,
1431    callee_targets: Vec<String>,
1432}
1433
1434impl RelationDisplayContext {
1435    fn from_query(query_str: &str) -> Self {
1436        match QueryParser::parse_query(query_str) {
1437            Ok(ast) => {
1438                let mut ctx = Self::default();
1439                collect_relation_targets(&ast.root, &mut ctx);
1440                ctx
1441            }
1442            Err(_) => Self::default(),
1443        }
1444    }
1445}
1446
1447fn collect_relation_targets(expr: &Expr, ctx: &mut RelationDisplayContext) {
1448    match expr {
1449        Expr::And(operands) | Expr::Or(operands) => {
1450            for operand in operands {
1451                collect_relation_targets(operand, ctx);
1452            }
1453        }
1454        Expr::Not(inner) => collect_relation_targets(inner, ctx),
1455        Expr::Join(join) => {
1456            collect_relation_targets(&join.left, ctx);
1457            collect_relation_targets(&join.right, ctx);
1458        }
1459        Expr::Condition(condition) => match condition.field.as_str() {
1460            "callers" => {
1461                if let Value::String(value) = &condition.value
1462                    && !value.is_empty()
1463                {
1464                    ctx.caller_targets.push(value.clone());
1465                }
1466            }
1467            "callees" => {
1468                if let Value::String(value) = &condition.value
1469                    && !value.is_empty()
1470                {
1471                    ctx.callee_targets.push(value.clone());
1472                }
1473            }
1474            _ => {}
1475        },
1476    }
1477}
1478
1479fn should_debug_cache(cli: &Cli) -> bool {
1480    cli.debug_cache || env_debug_cache_enabled()
1481}
1482
1483// RKG: CODE:SQRY-CLI implements REQ:SQRY-P2-6-CACHE-EVICTION-POLICY
1484fn maybe_emit_debug_cache(
1485    cli: &Cli,
1486    streams: &mut OutputStreams,
1487    executor_opt: Option<&QueryExecutor>,
1488    _stats: &SimpleQueryStats,
1489) -> Result<()> {
1490    if !should_debug_cache(cli) {
1491        return Ok(());
1492    }
1493
1494    let Some(executor) = executor_opt else {
1495        streams.write_diagnostic("CacheStats unavailable in this mode")?;
1496        return Ok(());
1497    };
1498
1499    let (parse_stats, result_stats) = executor.cache_stats();
1500
1501    let debug_line = format!(
1502        "CacheStats{{parse_hits={}, parse_misses={}, result_hits={}, result_misses={}}}",
1503        parse_stats.hits, parse_stats.misses, result_stats.hits, result_stats.misses,
1504    );
1505    streams.write_diagnostic(&debug_line)?;
1506    Ok(())
1507}
1508
1509/// Build hybrid search configuration from CLI flags
1510fn build_hybrid_config(cli: &Cli) -> FallbackConfig {
1511    let mut config = FallbackConfig::from_env();
1512
1513    // Override with CLI flags
1514    if cli.no_fallback {
1515        config.fallback_enabled = false;
1516    }
1517
1518    config.text_context_lines = cli.context;
1519    config.max_text_results = cli.max_text_results;
1520
1521    // Disable search mode output in JSON mode
1522    if cli.json {
1523        config.show_search_mode = false;
1524    }
1525
1526    config
1527}
1528
1529/// Determine if hybrid search should be used based on CLI flags
1530fn should_use_hybrid_search(cli: &Cli) -> bool {
1531    // Cache debugging requires direct access to QueryExecutor stats.
1532    if should_debug_cache(cli) {
1533        return false;
1534    }
1535
1536    // Always use hybrid search (it handles --text, --semantic, and hybrid modes)
1537    // The only reason NOT to use it would be if hybrid search is explicitly disabled
1538    // via environment variable or if we need old behavior for compatibility
1539    true
1540}
1541
1542/// Create a `QueryExecutor` with all built-in plugins registered
1543pub(crate) fn create_executor_with_plugins() -> QueryExecutor {
1544    let plugin_manager = crate::plugin_defaults::create_plugin_manager();
1545    QueryExecutor::with_plugin_manager(plugin_manager)
1546}
1547
1548pub(crate) fn create_executor_with_plugins_for_cli(
1549    cli: &Cli,
1550    search_path: &Path,
1551) -> Result<QueryExecutor> {
1552    let effective_root = find_nearest_index(search_path)
1553        .map_or_else(|| search_path.to_path_buf(), |location| location.index_root);
1554    let resolved_plugins = plugin_defaults::resolve_plugin_selection(
1555        cli,
1556        &effective_root,
1557        PluginSelectionMode::ReadOnly,
1558    )?;
1559    Ok(QueryExecutor::with_plugin_manager(
1560        resolved_plugins.plugin_manager,
1561    ))
1562}
1563
1564/// Strict path-only validation shared by every CLI `sqry query` mode.
1565///
1566/// SGA03 Major #3 / Major #4 fix — the migration tightened CLI path
1567/// handling so non-existent, non-canonicalizable, symlink-escaping, and
1568/// outside-workspace paths fail *before* any graph load, pipeline / join
1569/// dispatch, or text-only search runs. The provider already enforces this
1570/// for the semantic/hybrid graph-load path; this helper duplicates the
1571/// path-only portion so:
1572///
1573/// - Pipeline (`base | aggregation`) and join (`LHS CALLS RHS`) queries
1574///   that today route through `run_pipeline_query` / `run_join_query`
1575///   share the same strict invalid-path semantics as the regular
1576///   semantic path.
1577/// - `--text` mode (which deliberately skips full graph acquisition so
1578///   it keeps working on unindexed paths) still rejects invalid paths
1579///   up-front rather than failing later inside the text scanner with a
1580///   less informative diagnostic.
1581///
1582/// The check is intentionally tighter than `Path::exists`: it requires
1583/// canonicalization to succeed, which rejects dangling symlinks and
1584/// permission-denied paths at the boundary. A workspace-boundary check
1585/// can only be applied once we have a workspace root; for graph-backed
1586/// modes that's [`acquire_graph_for_cli`]'s job, while text-only mode
1587/// against an unindexed path simply has no workspace to bound.
1588/// Parse-only validation probe for the query string.
1589///
1590/// SGA03 regression fix — strict CLI path validation now runs before query
1591/// parsing in `acquire_graph_for_cli`, which means an invalid query string
1592/// against an *unindexed-but-valid* path was being masked by the provider's
1593/// `NoGraph` acquisition error (exit 1, "no graph found...") instead of
1594/// surfacing as a parse / validation error (exit 2).
1595///
1596/// The CLI integration spec (`docs/.../CLI_INTEGRATION.md` §4 Exit behavior)
1597/// requires invalid query syntax to remain a query-parse failure, not an
1598/// acquisition failure. This helper runs the executor's full parse and
1599/// validate step *without* loading any graph (purely an AST plus
1600/// field-registry check), so it can fire on unindexed directories the
1601/// same way a regular semantic query would have under the pre-SGA03
1602/// behavior.
1603///
1604/// Path validation already runs in `run_query` before this helper is
1605/// invoked, so an invalid *path* still wins over an invalid *query*. If the
1606/// path is valid but the directory has no graph, the parse probe reports
1607/// the parse error (exit 2); if the parse succeeds, control falls through
1608/// to `acquire_graph_for_cli` which reports the missing graph (exit 1).
1609///
1610/// Returns `Ok(())` when the query is well-formed; otherwise returns the
1611/// underlying [`QueryError`] / [`RichQueryError`] so the CLI's existing
1612/// error-mapping in `main::handle_run_error` produces exit code 2.
1613fn probe_validate_query_syntax(
1614    cli: &Cli,
1615    search_path: &Path,
1616    query_string: &str,
1617    validation_options: ValidationOptions,
1618) -> Result<()> {
1619    // Use the same plugin manager the executor would build for this path
1620    // so plugin-contributed query fields validate correctly. Falls back to
1621    // the default plugin manager if plugin resolution itself fails — that
1622    // failure mode will resurface in `acquire_graph_for_cli` with its
1623    // canonical diagnostic and the parse probe should not double-report.
1624    let executor = match create_executor_with_plugins_for_cli(cli, search_path) {
1625        Ok(executor) => executor.with_validation_options(validation_options),
1626        Err(_) => create_executor_with_plugins().with_validation_options(validation_options),
1627    };
1628    executor.parse_query_ast(query_string).map(|_| ())
1629}
1630
1631fn validate_query_path_strict(search_path: &Path) -> Result<PathBuf> {
1632    if !search_path.exists() {
1633        bail!(
1634            "invalid path {}: path does not exist",
1635            search_path.display()
1636        );
1637    }
1638    match search_path.canonicalize() {
1639        Ok(canonical) => Ok(canonical),
1640        Err(err) => bail!(
1641            "invalid path {}: path cannot be canonicalized: {err}",
1642            search_path.display()
1643        ),
1644    }
1645}
1646
1647/// Acquire the read-only graph for `search_path` through the shared
1648/// [`FilesystemGraphProvider`].
1649///
1650/// SGA03 routes CLI `sqry query` graph acquisition through the same provider
1651/// the standalone MCP engine uses. The provider owns:
1652///
1653/// 1. Strict path-policy validation (existence, workspace boundary,
1654///    symlink-escape rejection) **before** any disk graph load.
1655/// 2. Nearest `.sqry/graph` ancestor discovery (matching the CLI's existing
1656///    `find_nearest_index` semantics).
1657/// 3. Manifest SHA-256 verification, snapshot deserialization, and plugin
1658///    selection compatibility.
1659///
1660/// CLI `sqry query` is read-only at the snapshot-load layer but preserves the
1661/// pre-SGA03 auto-index-on-missing-graph behavior. When no `.sqry/graph`
1662/// exists for the resolved workspace, the provider invokes the
1663/// [`AutoBuildHook`] installed below, which honors `SQRY_AUTO_INDEX`
1664/// (default `true`) and otherwise returns a typed
1665/// [`GraphAcquisitionError::NoGraph`] so the CLI surfaces the existing
1666/// "No graph found" diagnostic at exit code 1.
1667///
1668/// `SQRY_AUTO_INDEX=false` (or `=0`) preserves the disabled-mode contract
1669/// exercised by `tests/exit_codes.rs::test_exit_code_1_no_graph_with_auto_index_disabled`.
1670/// The auto-build hook does **not** broaden any other error class into an
1671/// auto-build (see `CLI_INTEGRATION.md §2 Inputs And Flags`). It triggers
1672/// only on the no-artifact branch the provider takes when no ancestor
1673/// `.sqry/graph` is found.
1674pub(crate) fn acquire_graph_for_cli(cli: &Cli, search_path: &Path) -> Result<GraphAcquisition> {
1675    acquire_graph_for_cli_with_policy(cli, search_path, MissingGraphPolicy::AutoBuildIfEnabled)
1676}
1677
1678/// Variant of [`acquire_graph_for_cli`] that lets the caller pin the
1679/// missing-graph policy explicitly. The user-facing error is rendered by
1680/// [`map_acquisition_error`].
1681pub(crate) fn acquire_graph_for_cli_with_policy(
1682    cli: &Cli,
1683    search_path: &Path,
1684    missing_graph_policy: MissingGraphPolicy,
1685) -> Result<GraphAcquisition> {
1686    let (provider, request) =
1687        build_cli_provider_and_request(cli, search_path, missing_graph_policy)?;
1688    provider.acquire(request).map_err(map_acquisition_error)
1689}
1690
1691/// Typed-error variant for callers that need to discriminate
1692/// [`GraphAcquisitionError`] variants before they are rendered to the user
1693/// message.
1694///
1695/// Session mode (`--session`) uses this to swallow `NoGraph` so the
1696/// session-specific "no index found" diagnostic from `resolve_session_index`
1697/// remains the canonical user message (pinned by
1698/// `tests/integration_tests.rs::test_query_session_requires_index`), while
1699/// still propagating every other acquisition error (invalid path,
1700/// incompatible graph, load failure).
1701///
1702/// Plugin-selection / configuration failures still surface through the
1703/// shared `anyhow::Error` path (they are CLI-config problems, not graph-
1704/// acquisition outcomes), so the outer return type is still `Result`. The
1705/// inner `Result` is the typed [`GraphAcquisitionError`] for the callers
1706/// that need to inspect it.
1707pub(crate) fn acquire_graph_for_cli_typed(
1708    cli: &Cli,
1709    search_path: &Path,
1710    missing_graph_policy: MissingGraphPolicy,
1711) -> Result<std::result::Result<GraphAcquisition, GraphAcquisitionError>> {
1712    let (provider, request) =
1713        build_cli_provider_and_request(cli, search_path, missing_graph_policy)?;
1714    Ok(provider.acquire(request))
1715}
1716
1717/// Shared provider/request construction for the CLI's filesystem-backed
1718/// graph acquisition. Resolves CLI plugin selection (which may surface
1719/// `anyhow::Error`-shaped configuration failures), wires the optional
1720/// `AutoBuildHook` for [`MissingGraphPolicy::AutoBuildIfEnabled`], and
1721/// returns the configured provider plus the read-only `GraphAcquisitionRequest`.
1722fn build_cli_provider_and_request(
1723    cli: &Cli,
1724    search_path: &Path,
1725    missing_graph_policy: MissingGraphPolicy,
1726) -> Result<(FilesystemGraphProvider, GraphAcquisitionRequest)> {
1727    // Resolve the same plugin selection the executor would use; the provider
1728    // needs it both for snapshot deserialization and for unknown-plugin-id
1729    // detection on the manifest.
1730    //
1731    // The plugin resolution uses an "effective root" that may not be the same
1732    // as the canonical workspace the provider discovers. To preserve current
1733    // CLI behavior we resolve plugins against the user-supplied search path
1734    // first; if that fails because the workspace cannot be located, the
1735    // provider call below will surface the canonical typed error.
1736    let plugin_root = find_nearest_index(search_path)
1737        .map_or_else(|| search_path.to_path_buf(), |location| location.index_root);
1738    let resolved_plugins = plugin_defaults::resolve_plugin_selection(
1739        cli,
1740        &plugin_root,
1741        PluginSelectionMode::ReadOnly,
1742    )?;
1743    let mut provider = FilesystemGraphProvider::new(Arc::new(resolved_plugins.plugin_manager));
1744
1745    // Only attach the auto-build hook for `AutoBuildIfEnabled` callers.
1746    // Session-mode callers pass `Error` so missing graphs surface as a typed
1747    // `NoGraph` error and the session-specific "no index found" diagnostic
1748    // can run.
1749    if matches!(missing_graph_policy, MissingGraphPolicy::AutoBuildIfEnabled) {
1750        // Resolve a second plugin manager with the same selection so the
1751        // auto-build hook can move an `Arc<PluginManager>` into a `'static`
1752        // closure without giving up the provider's manager. `PluginManager`
1753        // is not `Clone`; both managers carry identical selection because
1754        // `resolve_plugin_selection(ReadOnly)` is deterministic for the
1755        // same (cli, plugin_root) inputs and there is no `.sqry/graph` to
1756        // invalidate here (we're on the missing-artifact branch).
1757        let hook_plugins = plugin_defaults::resolve_plugin_selection(
1758            cli,
1759            &plugin_root,
1760            PluginSelectionMode::ReadOnly,
1761        )?;
1762        let hook_plugin_manager = Arc::new(hook_plugins.plugin_manager);
1763
1764        let auto_build_hook: AutoBuildHook = Arc::new(move |canonical_request: &Path| {
1765            // Mirror `Engine::ensure_graph` (Gate A iter 1): `SQRY_AUTO_INDEX`
1766            // gate first; if disabled, surface `NoGraph` so the CLI's
1767            // existing `map_acquisition_error` produces "No graph found ..."
1768            // (preserving the pre-SGA03 exit-1 contract). Do NOT broaden
1769            // auto-index semantics — only the no-artifact branch reaches
1770            // this hook.
1771            if !is_auto_index_enabled() {
1772                return Err(GraphAcquisitionError::NoGraph {
1773                    workspace_root: canonical_request.to_path_buf(),
1774                });
1775            }
1776
1777            log::info!(
1778                "No graph found at {}, auto-building index",
1779                canonical_request.display()
1780            );
1781
1782            let config = sqry_core::graph::unified::build::BuildConfig::default();
1783            let (graph, _build_result) = sqry_core::graph::unified::build::build_and_persist_graph(
1784                canonical_request,
1785                &hook_plugin_manager,
1786                &config,
1787                "cli:auto_index",
1788            )
1789            .map_err(|e| GraphAcquisitionError::BuildFailed {
1790                workspace_root: canonical_request.to_path_buf(),
1791                reason: format!("{e}"),
1792            })?;
1793            Ok(Arc::new(graph))
1794        });
1795
1796        provider = provider.with_auto_build_hook(auto_build_hook);
1797    }
1798
1799    let request = GraphAcquisitionRequest {
1800        requested_path: search_path.to_path_buf(),
1801        operation: AcquisitionOperation::ReadOnlyQuery,
1802        path_policy: PathPolicy::default(),
1803        missing_graph_policy,
1804        stale_policy: StalePolicy::default(),
1805        plugin_selection_policy: PluginSelectionPolicy::default(),
1806        tool_name: Some("sqry_query"),
1807    };
1808    Ok((provider, request))
1809}
1810
1811/// Returns `true` when CLI auto-indexing is enabled (the default).
1812///
1813/// Mirrors `sqry-mcp::engine::is_auto_index_enabled`: `SQRY_AUTO_INDEX=false`
1814/// or `SQRY_AUTO_INDEX=0` disables auto-indexing; any other value (including
1815/// unset) enables it. Kept local to the CLI to avoid pulling sqry-mcp into
1816/// the CLI's dependency graph.
1817fn is_auto_index_enabled() -> bool {
1818    match std::env::var("SQRY_AUTO_INDEX") {
1819        Ok(val) => val != "false" && val != "0",
1820        Err(_) => true,
1821    }
1822}
1823
1824/// Map a typed [`GraphAcquisitionError`] into an `anyhow::Error` while
1825/// preserving the variant identity so CLI diagnostics can distinguish path
1826/// errors from incompatible-graph errors.
1827fn map_acquisition_error(err: GraphAcquisitionError) -> anyhow::Error {
1828    match err {
1829        GraphAcquisitionError::InvalidPath { path, reason } => {
1830            anyhow::anyhow!("invalid path {}: {}", path.display(), reason)
1831        }
1832        GraphAcquisitionError::NoGraph { workspace_root } => {
1833            anyhow::anyhow!(
1834                "No graph found for {}. Run `sqry index {}` first.",
1835                workspace_root.display(),
1836                workspace_root.display()
1837            )
1838        }
1839        GraphAcquisitionError::IncompatibleGraph {
1840            source_root,
1841            status,
1842        } => match status {
1843            PluginSelectionStatus::IncompatibleUnknownPluginIds { unknown_plugin_ids } => {
1844                anyhow::anyhow!(
1845                    "Incompatible graph at {}: manifest references plugin ids unknown to this binary: {}. \
1846                     Rebuild the index with `sqry index {} --force` after upgrading sqry.",
1847                    source_root.display(),
1848                    unknown_plugin_ids.join(", "),
1849                    source_root.display()
1850                )
1851            }
1852            PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => anyhow::anyhow!(
1853                "Incompatible graph at {}: {}. Run `sqry index {} --force` to rebuild.",
1854                source_root.display(),
1855                reason,
1856                source_root.display()
1857            ),
1858            PluginSelectionStatus::Exact => {
1859                anyhow::anyhow!(
1860                    "Incompatible graph at {} (no detail); rerun `sqry index --force`",
1861                    source_root.display()
1862                )
1863            }
1864        },
1865        GraphAcquisitionError::LoadFailed {
1866            source_root,
1867            reason,
1868        } => anyhow::anyhow!(
1869            "Failed to load graph at {}: {}",
1870            source_root.display(),
1871            reason
1872        ),
1873        other => anyhow::anyhow!("graph acquisition failed: {other}"),
1874    }
1875}
1876
1877fn u64_to_f64_lossy(value: u64) -> f64 {
1878    let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
1879    f64::from(narrowed)
1880}
1881
1882// ============================================================================
1883// Variable, Join, and Pipeline support
1884// ============================================================================
1885
1886/// Parse `--var KEY=VALUE` arguments into a `HashMap`.
1887fn parse_variable_args(args: &[String]) -> Result<std::collections::HashMap<String, String>> {
1888    let mut map = std::collections::HashMap::new();
1889    for arg in args {
1890        let (key, value) = arg
1891            .split_once('=')
1892            .ok_or_else(|| anyhow::anyhow!("Invalid --var format: '{arg}'. Expected KEY=VALUE"))?;
1893        if key.is_empty() {
1894            bail!("Variable name cannot be empty in --var '{arg}'");
1895        }
1896        map.insert(key.to_string(), value.to_string());
1897    }
1898    Ok(map)
1899}
1900
1901/// Check if a query string contains a join expression at the root level.
1902///
1903/// Returns `false` on parse errors (the normal flow will handle the error).
1904fn is_join_query(query_str: &str) -> bool {
1905    match QueryParser::parse_query(query_str) {
1906        Ok(ast) => matches!(ast.root, Expr::Join(_)),
1907        Err(_) => false,
1908    }
1909}
1910
1911/// Detect a pipeline query (base query | aggregation stages).
1912///
1913/// If the query string contains a `|` character, pipeline parse errors are
1914/// treated as hard errors (the user intended a pipeline query). If no `|`
1915/// is present, returns `None` (not a pipeline query).
1916fn detect_pipeline_query(
1917    query_str: &str,
1918) -> Result<Option<sqry_core::query::types::PipelineQuery>> {
1919    match QueryParser::parse_pipeline_query(query_str) {
1920        Ok(result) => Ok(result),
1921        Err(e) => {
1922            // If the query contains a pipe, the user intended a pipeline query
1923            // and the parse error should be surfaced (not silently ignored).
1924            if query_str.contains('|') {
1925                Err(anyhow::anyhow!("Pipeline parse error: {e}"))
1926            } else {
1927                Ok(None)
1928            }
1929        }
1930    }
1931}
1932
1933/// Run a join query and render results.
1934fn run_join_query(
1935    cli: &Cli,
1936    streams: &mut OutputStreams,
1937    query_string: &str,
1938    search_path: &str,
1939    no_parallel: bool,
1940    variables: Option<&std::collections::HashMap<String, String>>,
1941) -> Result<()> {
1942    let validation_options = build_validation_options(cli);
1943    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
1944        .with_validation_options(validation_options);
1945    if no_parallel {
1946        executor = executor.without_parallel();
1947    }
1948
1949    let resolved_path = Path::new(search_path);
1950    let join_results = executor.execute_join(query_string, resolved_path, variables)?;
1951
1952    if join_results.truncated() {
1953        streams.write_diagnostic(&format!(
1954            "Join query: {} pairs matched via {} (results truncated — cap reached)",
1955            join_results.len(),
1956            join_results.edge_kind()
1957        ))?;
1958    } else {
1959        streams.write_diagnostic(&format!(
1960            "Join query: {} pairs matched via {}",
1961            join_results.len(),
1962            join_results.edge_kind()
1963        ))?;
1964    }
1965
1966    for pair in join_results.iter() {
1967        let left_name = pair.left.name().unwrap_or_default();
1968        let left_path = pair
1969            .left
1970            .relative_path()
1971            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1972        let right_name = pair.right.name().unwrap_or_default();
1973        let right_path = pair
1974            .right
1975            .relative_path()
1976            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1977
1978        if cli.json {
1979            // JSON mode: each pair as a JSON object
1980            let json = serde_json::json!({
1981                "left": {
1982                    "name": left_name.as_ref(),
1983                    "kind": pair.left.kind().as_str(),
1984                    "path": left_path,
1985                    "line": pair.left.start_line(),
1986                },
1987                "edge": pair.edge_kind.to_string(),
1988                "right": {
1989                    "name": right_name.as_ref(),
1990                    "kind": pair.right.kind().as_str(),
1991                    "path": right_path,
1992                    "line": pair.right.start_line(),
1993                },
1994            });
1995            streams.write_result(&json.to_string())?;
1996        } else {
1997            streams.write_result(&format!(
1998                "{} ({}:{}) {} {} ({}:{})",
1999                left_name,
2000                left_path,
2001                pair.left.start_line(),
2002                pair.edge_kind,
2003                right_name,
2004                right_path,
2005                pair.right.start_line(),
2006            ))?;
2007        }
2008    }
2009
2010    Ok(())
2011}
2012
2013/// Run a pipeline query (base query + aggregation stages) and render results.
2014fn run_pipeline_query(
2015    cli: &Cli,
2016    streams: &mut OutputStreams,
2017    _query_string: &str,
2018    search_path: &str,
2019    pipeline: &sqry_core::query::types::PipelineQuery,
2020    no_parallel: bool,
2021    variables: Option<&std::collections::HashMap<String, String>>,
2022) -> Result<()> {
2023    let validation_options = build_validation_options(cli);
2024    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
2025        .with_validation_options(validation_options);
2026    if no_parallel {
2027        executor = executor.without_parallel();
2028    }
2029
2030    let resolved_path = Path::new(search_path);
2031
2032    // Execute the base query portion (before the pipe)
2033    // Serialize the base query from the parsed AST for reliable reconstruction
2034    let base_query = sqry_core::query::parsed_query::serialize_query(&pipeline.query);
2035
2036    let results =
2037        executor.execute_on_graph_with_variables(&base_query, resolved_path, variables)?;
2038
2039    // Execute each pipeline stage
2040    for stage in &pipeline.stages {
2041        let aggregation = sqry_core::query::execute_pipeline_stage(&results, stage);
2042
2043        if cli.json {
2044            render_aggregation_json(streams, &aggregation)?;
2045        } else {
2046            streams.write_result(&format!("{aggregation}"))?;
2047        }
2048    }
2049
2050    Ok(())
2051}
2052
2053/// Render aggregation results as JSON.
2054fn render_aggregation_json(
2055    streams: &mut OutputStreams,
2056    aggregation: &sqry_core::query::pipeline::AggregationResult,
2057) -> Result<()> {
2058    use sqry_core::query::pipeline::AggregationResult;
2059    let json = match aggregation {
2060        AggregationResult::Count(r) => serde_json::json!({
2061            "type": "count",
2062            "total": r.total,
2063        }),
2064        AggregationResult::GroupBy(r) => serde_json::json!({
2065            "type": "group_by",
2066            "field": r.field,
2067            "groups": r.groups.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2068        }),
2069        AggregationResult::Top(r) => serde_json::json!({
2070            "type": "top",
2071            "field": r.field,
2072            "n": r.n,
2073            "entries": r.entries.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2074        }),
2075        AggregationResult::Stats(r) => serde_json::json!({
2076            "type": "stats",
2077            "total": r.total,
2078            "by_kind": r.by_kind.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2079            "by_lang": r.by_lang.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2080            "by_visibility": r.by_visibility.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2081        }),
2082    };
2083    streams.write_result(&json.to_string())?;
2084    Ok(())
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089    use super::*;
2090    use sqry_core::relations::CallIdentityKind;
2091
2092    // ==========================================================================
2093    // u64_to_f64_lossy tests
2094    // ==========================================================================
2095
2096    #[test]
2097    fn test_u64_to_f64_lossy_zero() {
2098        assert!((u64_to_f64_lossy(0) - 0.0).abs() < f64::EPSILON);
2099    }
2100
2101    #[test]
2102    fn test_u64_to_f64_lossy_small_values() {
2103        assert!((u64_to_f64_lossy(1) - 1.0).abs() < f64::EPSILON);
2104        assert!((u64_to_f64_lossy(100) - 100.0).abs() < f64::EPSILON);
2105        assert!((u64_to_f64_lossy(1000) - 1000.0).abs() < f64::EPSILON);
2106    }
2107
2108    #[test]
2109    fn test_u64_to_f64_lossy_u32_max() {
2110        let u32_max = u64::from(u32::MAX);
2111        assert!((u64_to_f64_lossy(u32_max) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2112    }
2113
2114    #[test]
2115    fn test_u64_to_f64_lossy_overflow_clamps_to_u32_max() {
2116        // Values larger than u32::MAX should clamp
2117        let large_value = u64::from(u32::MAX) + 1;
2118        assert!((u64_to_f64_lossy(large_value) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2119    }
2120
2121    // ==========================================================================
2122    // format_cache_status tests
2123    // ==========================================================================
2124
2125    #[test]
2126    fn test_format_cache_status_full_hit() {
2127        let status = sqry_core::query::CacheStatus {
2128            parse_cache_hit: true,
2129            result_cache_hit: true,
2130        };
2131        assert_eq!(format_cache_status(&status), "HIT (100% cached)");
2132    }
2133
2134    #[test]
2135    fn test_format_cache_status_parse_hit_only() {
2136        let status = sqry_core::query::CacheStatus {
2137            parse_cache_hit: true,
2138            result_cache_hit: false,
2139        };
2140        assert_eq!(
2141            format_cache_status(&status),
2142            "PARTIAL HIT (query cached, results computed)"
2143        );
2144    }
2145
2146    #[test]
2147    fn test_format_cache_status_result_hit_only() {
2148        let status = sqry_core::query::CacheStatus {
2149            parse_cache_hit: false,
2150            result_cache_hit: true,
2151        };
2152        assert_eq!(
2153            format_cache_status(&status),
2154            "PARTIAL HIT (query parsed, results cached)"
2155        );
2156    }
2157
2158    #[test]
2159    fn test_format_cache_status_full_miss() {
2160        let status = sqry_core::query::CacheStatus {
2161            parse_cache_hit: false,
2162            result_cache_hit: false,
2163        };
2164        assert_eq!(format_cache_status(&status), "MISS (first run)");
2165    }
2166
2167    // ==========================================================================
2168    // format_execution_steps tests
2169    // ==========================================================================
2170
2171    #[test]
2172    fn test_format_execution_steps_empty() {
2173        let steps: Vec<sqry_core::query::ExecutionStep> = vec![];
2174        assert_eq!(format_execution_steps(&steps), "");
2175    }
2176
2177    #[test]
2178    fn test_format_execution_steps_single() {
2179        let steps = vec![sqry_core::query::ExecutionStep {
2180            step_num: 1,
2181            operation: "Parse query".to_string(),
2182            result_count: 0,
2183            time_ms: 5,
2184        }];
2185        assert_eq!(format_execution_steps(&steps), "  1. Parse query (5ms)");
2186    }
2187
2188    #[test]
2189    fn test_format_execution_steps_multiple() {
2190        let steps = vec![
2191            sqry_core::query::ExecutionStep {
2192                step_num: 1,
2193                operation: "Parse".to_string(),
2194                result_count: 100,
2195                time_ms: 2,
2196            },
2197            sqry_core::query::ExecutionStep {
2198                step_num: 2,
2199                operation: "Optimize".to_string(),
2200                result_count: 50,
2201                time_ms: 3,
2202            },
2203            sqry_core::query::ExecutionStep {
2204                step_num: 3,
2205                operation: "Execute".to_string(),
2206                result_count: 25,
2207                time_ms: 10,
2208            },
2209        ];
2210        let expected = "  1. Parse (2ms)\n  2. Optimize (3ms)\n  3. Execute (10ms)";
2211        assert_eq!(format_execution_steps(&steps), expected);
2212    }
2213
2214    // ==========================================================================
2215    // expr_has_repo_predicate tests
2216    // ==========================================================================
2217
2218    #[test]
2219    fn test_expr_has_repo_predicate_simple_repo() {
2220        let query = QueryParser::parse_query("repo:myrepo").unwrap();
2221        assert!(expr_has_repo_predicate(&query.root));
2222    }
2223
2224    #[test]
2225    fn test_expr_has_repo_predicate_no_repo() {
2226        let query = QueryParser::parse_query("kind:function").unwrap();
2227        assert!(!expr_has_repo_predicate(&query.root));
2228    }
2229
2230    #[test]
2231    fn test_expr_has_repo_predicate_nested_and() {
2232        let query = QueryParser::parse_query("kind:function AND repo:myrepo").unwrap();
2233        assert!(expr_has_repo_predicate(&query.root));
2234    }
2235
2236    #[test]
2237    fn test_expr_has_repo_predicate_nested_or() {
2238        let query = QueryParser::parse_query("kind:function OR repo:myrepo").unwrap();
2239        assert!(expr_has_repo_predicate(&query.root));
2240    }
2241
2242    #[test]
2243    fn test_expr_has_repo_predicate_nested_not() {
2244        let query = QueryParser::parse_query("NOT repo:myrepo").unwrap();
2245        assert!(expr_has_repo_predicate(&query.root));
2246    }
2247
2248    #[test]
2249    fn test_expr_has_repo_predicate_complex_no_repo() {
2250        let query = QueryParser::parse_query("kind:function AND name:foo OR lang:rust").unwrap();
2251        assert!(!expr_has_repo_predicate(&query.root));
2252    }
2253
2254    // ==========================================================================
2255    // RelationDisplayContext tests
2256    // ==========================================================================
2257
2258    #[test]
2259    fn test_relation_context_no_relations() {
2260        let ctx = RelationDisplayContext::from_query("kind:function");
2261        assert!(ctx.caller_targets.is_empty());
2262        assert!(ctx.callee_targets.is_empty());
2263    }
2264
2265    #[test]
2266    fn test_relation_context_with_callers() {
2267        let ctx = RelationDisplayContext::from_query("callers:foo");
2268        assert_eq!(ctx.caller_targets, vec!["foo"]);
2269        assert!(ctx.callee_targets.is_empty());
2270    }
2271
2272    #[test]
2273    fn test_relation_context_with_callees() {
2274        let ctx = RelationDisplayContext::from_query("callees:bar");
2275        assert!(ctx.caller_targets.is_empty());
2276        assert_eq!(ctx.callee_targets, vec!["bar"]);
2277    }
2278
2279    #[test]
2280    fn test_relation_context_with_both() {
2281        let ctx = RelationDisplayContext::from_query("callers:foo AND callees:bar");
2282        assert_eq!(ctx.caller_targets, vec!["foo"]);
2283        assert_eq!(ctx.callee_targets, vec!["bar"]);
2284    }
2285
2286    #[test]
2287    fn test_relation_context_invalid_query() {
2288        // Invalid queries should return default context
2289        let ctx = RelationDisplayContext::from_query("invalid query syntax ???");
2290        assert!(ctx.caller_targets.is_empty());
2291        assert!(ctx.callee_targets.is_empty());
2292    }
2293
2294    #[test]
2295    fn test_build_identity_from_qualified_name_preserves_ruby_instance_display() {
2296        let identity = build_identity_from_qualified_name(
2297            "Admin::Users::Controller::show",
2298            "method",
2299            Some("ruby"),
2300            false,
2301        )
2302        .expect("ruby instance identity");
2303
2304        assert_eq!(identity.qualified, "Admin::Users::Controller#show");
2305        assert_eq!(identity.method_kind, CallIdentityKind::Instance);
2306    }
2307
2308    #[test]
2309    fn test_build_identity_from_qualified_name_preserves_ruby_singleton_display() {
2310        let identity = build_identity_from_qualified_name(
2311            "Admin::Users::Controller::show",
2312            "method",
2313            Some("ruby"),
2314            true,
2315        )
2316        .expect("ruby singleton identity");
2317
2318        assert_eq!(identity.qualified, "Admin::Users::Controller.show");
2319        assert_eq!(identity.method_kind, CallIdentityKind::Singleton);
2320    }
2321
2322    // ==========================================================================
2323    // ensure_repo_predicate_not_present tests
2324    // ==========================================================================
2325
2326    #[test]
2327    fn test_ensure_repo_not_present_ok() {
2328        let result = ensure_repo_predicate_not_present("kind:function");
2329        assert!(result.is_ok());
2330    }
2331
2332    #[test]
2333    fn test_ensure_repo_not_present_fails_with_repo() {
2334        let result = ensure_repo_predicate_not_present("repo:myrepo");
2335        assert!(result.is_err());
2336        assert!(
2337            result
2338                .unwrap_err()
2339                .to_string()
2340                .contains("repo: filters are only supported")
2341        );
2342    }
2343
2344    #[test]
2345    fn test_ensure_repo_not_present_fails_with_nested_repo() {
2346        let result = ensure_repo_predicate_not_present("kind:function AND repo:myrepo");
2347        assert!(result.is_err());
2348    }
2349
2350    #[test]
2351    fn test_ensure_repo_not_present_fallback_text_check() {
2352        // Even if query doesn't parse, text-based check should work
2353        let result = ensure_repo_predicate_not_present("invalid??? repo:something");
2354        assert!(result.is_err());
2355    }
2356
2357    // ==========================================================================
2358    // parse_variable_args tests
2359    // ==========================================================================
2360
2361    #[test]
2362    fn test_parse_variable_args_empty() {
2363        let result = parse_variable_args(&[]).unwrap();
2364        assert!(result.is_empty());
2365    }
2366
2367    #[test]
2368    fn test_parse_variable_args_single_key_value() {
2369        let args = vec!["FOO=bar".to_string()];
2370        let result = parse_variable_args(&args).unwrap();
2371        assert_eq!(result.len(), 1);
2372        assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
2373    }
2374
2375    #[test]
2376    fn test_parse_variable_args_multiple() {
2377        let args = vec!["A=1".to_string(), "B=hello world".to_string()];
2378        let result = parse_variable_args(&args).unwrap();
2379        assert_eq!(result.len(), 2);
2380        assert_eq!(result.get("A"), Some(&"1".to_string()));
2381        assert_eq!(result.get("B"), Some(&"hello world".to_string()));
2382    }
2383
2384    #[test]
2385    fn test_parse_variable_args_value_with_equals() {
2386        // Only the first '=' is the separator; rest is the value
2387        let args = vec!["KEY=val=ue".to_string()];
2388        let result = parse_variable_args(&args).unwrap();
2389        assert_eq!(result.get("KEY"), Some(&"val=ue".to_string()));
2390    }
2391
2392    #[test]
2393    fn test_parse_variable_args_no_equals_errors() {
2394        let args = vec!["NOEQUALS".to_string()];
2395        let err = parse_variable_args(&args).unwrap_err();
2396        assert!(
2397            err.to_string().contains("Invalid --var format"),
2398            "Unexpected error: {err}"
2399        );
2400    }
2401
2402    #[test]
2403    fn test_parse_variable_args_empty_key_errors() {
2404        let args = vec!["=value".to_string()];
2405        let err = parse_variable_args(&args).unwrap_err();
2406        assert!(
2407            err.to_string().contains("Variable name cannot be empty"),
2408            "Unexpected error: {err}"
2409        );
2410    }
2411
2412    #[test]
2413    fn test_parse_variable_args_empty_value_allowed() {
2414        let args = vec!["KEY=".to_string()];
2415        let result = parse_variable_args(&args).unwrap();
2416        assert_eq!(result.get("KEY"), Some(&String::new()));
2417    }
2418
2419    // ==========================================================================
2420    // is_join_query tests
2421    // ==========================================================================
2422
2423    #[test]
2424    fn test_is_join_query_non_join() {
2425        assert!(!is_join_query("kind:function"));
2426        assert!(!is_join_query("name:foo AND kind:method"));
2427    }
2428
2429    #[test]
2430    fn test_is_join_query_invalid_query_returns_false() {
2431        // parse errors → false, not panic
2432        assert!(!is_join_query("invalid ??? syntax {{{"));
2433    }
2434
2435    #[test]
2436    fn test_is_join_query_positive() {
2437        // A valid join expression uses the CALLS operator between two sub-queries.
2438        // The parser recognises `(lhs) CALLS (rhs)` as a Join expression.
2439        assert!(
2440            is_join_query("(kind:function) CALLS (kind:function)"),
2441            "CALLS join expression must be detected as a join query"
2442        );
2443    }
2444
2445    // ==========================================================================
2446    // detect_pipeline_query tests
2447    // ==========================================================================
2448
2449    #[test]
2450    fn test_detect_pipeline_query_no_pipe_returns_none() {
2451        let result = detect_pipeline_query("kind:function").unwrap();
2452        assert!(result.is_none());
2453    }
2454
2455    #[test]
2456    fn test_detect_pipeline_query_invalid_without_pipe_returns_none() {
2457        // No pipe → even parse errors silently return None
2458        let result = detect_pipeline_query("invalid query !!!").unwrap();
2459        assert!(result.is_none());
2460    }
2461
2462    #[test]
2463    fn test_detect_pipeline_query_invalid_with_pipe_errors() {
2464        // A well-formed pipeline query (base `|` valid stage) must return Ok.
2465        // The presence of `|` only turns parse *errors* into hard errors; a
2466        // successful parse must always return Ok(Some(_)).
2467        let result = detect_pipeline_query("kind:function | count");
2468        assert!(
2469            result.is_ok(),
2470            "A valid pipeline query must return Ok, got: {result:?}"
2471        );
2472        assert!(
2473            result.unwrap().is_some(),
2474            "A valid pipeline query must return Ok(Some(_))"
2475        );
2476    }
2477
2478    // ==========================================================================
2479    // apply_symbol_limit tests
2480    // ==========================================================================
2481
2482    #[test]
2483    fn test_apply_symbol_limit_no_truncation() {
2484        let mut symbols: Vec<DisplaySymbol> = (0..5)
2485            .map(|i| DisplaySymbol {
2486                name: format!("sym{i}"),
2487                qualified_name: format!("sym{i}"),
2488                kind: "function".to_string(),
2489                file_path: std::path::PathBuf::from("a.rs"),
2490                start_line: i,
2491                start_column: 0,
2492                end_line: i,
2493                end_column: 0,
2494                metadata: std::collections::HashMap::new(),
2495                caller_identity: None,
2496                callee_identity: None,
2497            })
2498            .collect();
2499
2500        let info = apply_symbol_limit(&mut symbols, 10);
2501        assert_eq!(symbols.len(), 5);
2502        assert!(!info.truncated);
2503        assert_eq!(info.total_matches, 5);
2504        assert_eq!(info.limit, 10);
2505    }
2506
2507    #[test]
2508    fn test_apply_symbol_limit_truncates() {
2509        let mut symbols: Vec<DisplaySymbol> = (0..20)
2510            .map(|i| DisplaySymbol {
2511                name: format!("sym{i}"),
2512                qualified_name: format!("sym{i}"),
2513                kind: "function".to_string(),
2514                file_path: std::path::PathBuf::from("a.rs"),
2515                start_line: i,
2516                start_column: 0,
2517                end_line: i,
2518                end_column: 0,
2519                metadata: std::collections::HashMap::new(),
2520                caller_identity: None,
2521                callee_identity: None,
2522            })
2523            .collect();
2524
2525        let info = apply_symbol_limit(&mut symbols, 5);
2526        assert_eq!(symbols.len(), 5);
2527        assert!(info.truncated);
2528        assert_eq!(info.total_matches, 20);
2529        assert_eq!(info.limit, 5);
2530    }
2531
2532    #[test]
2533    fn test_apply_symbol_limit_exact_boundary() {
2534        let mut symbols: Vec<DisplaySymbol> = (0..5)
2535            .map(|i| DisplaySymbol {
2536                name: format!("sym{i}"),
2537                qualified_name: format!("sym{i}"),
2538                kind: "function".to_string(),
2539                file_path: std::path::PathBuf::from("a.rs"),
2540                start_line: i,
2541                start_column: 0,
2542                end_line: i,
2543                end_column: 0,
2544                metadata: std::collections::HashMap::new(),
2545                caller_identity: None,
2546                callee_identity: None,
2547            })
2548            .collect();
2549
2550        let info = apply_symbol_limit(&mut symbols, 5);
2551        assert_eq!(symbols.len(), 5);
2552        assert!(!info.truncated, "Exact boundary should not truncate");
2553    }
2554
2555    // ==========================================================================
2556    // u64_to_f64_lossy additional edge cases
2557    // ==========================================================================
2558
2559    #[test]
2560    fn test_u64_to_f64_lossy_large_values_clamp_to_u32_max() {
2561        let very_large = u64::MAX;
2562        let result = u64_to_f64_lossy(very_large);
2563        // Should clamp to u32::MAX
2564        assert!((result - f64::from(u32::MAX)).abs() < f64::EPSILON);
2565    }
2566
2567    // ==========================================================================
2568    // env_debug_cache_enabled tests
2569    // ==========================================================================
2570
2571    #[serial_test::serial]
2572    #[test]
2573    fn test_env_debug_cache_disabled_by_default() {
2574        // In a clean test environment, SQRY_CACHE_DEBUG should not be set
2575        // (if it is set externally, we skip this test)
2576        unsafe {
2577            std::env::remove_var("SQRY_CACHE_DEBUG");
2578        }
2579        assert!(!env_debug_cache_enabled());
2580    }
2581
2582    #[serial_test::serial]
2583    #[test]
2584    fn test_env_debug_cache_enabled_with_1() {
2585        unsafe {
2586            std::env::set_var("SQRY_CACHE_DEBUG", "1");
2587        }
2588        let result = env_debug_cache_enabled();
2589        unsafe {
2590            std::env::remove_var("SQRY_CACHE_DEBUG");
2591        }
2592        assert!(result);
2593    }
2594
2595    #[serial_test::serial]
2596    #[test]
2597    fn test_env_debug_cache_enabled_with_true() {
2598        unsafe {
2599            std::env::set_var("SQRY_CACHE_DEBUG", "true");
2600        }
2601        let result = env_debug_cache_enabled();
2602        unsafe {
2603            std::env::remove_var("SQRY_CACHE_DEBUG");
2604        }
2605        assert!(result);
2606    }
2607
2608    #[serial_test::serial]
2609    #[test]
2610    fn test_env_debug_cache_enabled_with_true_uppercase() {
2611        unsafe {
2612            std::env::set_var("SQRY_CACHE_DEBUG", "TRUE");
2613        }
2614        let result = env_debug_cache_enabled();
2615        unsafe {
2616            std::env::remove_var("SQRY_CACHE_DEBUG");
2617        }
2618        assert!(result);
2619    }
2620
2621    #[serial_test::serial]
2622    #[test]
2623    fn test_env_debug_cache_disabled_with_zero() {
2624        unsafe {
2625            std::env::set_var("SQRY_CACHE_DEBUG", "0");
2626        }
2627        let result = env_debug_cache_enabled();
2628        unsafe {
2629            std::env::remove_var("SQRY_CACHE_DEBUG");
2630        }
2631        assert!(!result);
2632    }
2633
2634    // ==========================================================================
2635    // build_query_stats tests
2636    // ==========================================================================
2637
2638    #[test]
2639    fn test_build_query_stats_with_index() {
2640        let stats = build_query_stats(true, 10);
2641        assert!(stats.used_index);
2642    }
2643
2644    #[test]
2645    fn test_build_query_stats_without_index() {
2646        let stats = build_query_stats(false, 10);
2647        assert!(!stats.used_index);
2648    }
2649}