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 {
1844                unknown_plugin_ids,
1845                manifest_path,
1846            } => {
1847                let suggested = sqry_plugin_registry::missing_features_for(&unknown_plugin_ids);
1848                let all_have_features =
1849                    sqry_plugin_registry::all_unknown_ids_have_features(&unknown_plugin_ids);
1850                let manifest_str = manifest_path
1851                    .as_ref()
1852                    .map(|p| p.display().to_string())
1853                    .unwrap_or_else(|| "<unknown>".to_string());
1854                let suggestion = if !suggested.is_empty() {
1855                    format!(
1856                        "Rebuild this binary with `cargo install --path sqry-cli --features {}` \
1857                         or rebuild the index: `sqry index {} --force`.",
1858                        suggested.join(","),
1859                        source_root.display(),
1860                    )
1861                } else if all_have_features {
1862                    format!(
1863                        "Rebuild the index with the binary that produced it: \
1864                         `sqry index {} --force`.",
1865                        source_root.display(),
1866                    )
1867                } else {
1868                    format!(
1869                        "The unknown ids do not match any known feature flag — \
1870                         the manifest may be from a newer sqry version. \
1871                         Rebuild the index: `sqry index {} --force`.",
1872                        source_root.display(),
1873                    )
1874                };
1875                anyhow::anyhow!(
1876                    "Incompatible graph at {} — manifest references plugins this binary \
1877                     cannot load: {}. Manifest: {}. {}",
1878                    source_root.display(),
1879                    unknown_plugin_ids.join(", "),
1880                    manifest_str,
1881                    suggestion,
1882                )
1883            }
1884            PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => anyhow::anyhow!(
1885                "Incompatible graph at {}: {}. Run `sqry index {} --force` to rebuild.",
1886                source_root.display(),
1887                reason,
1888                source_root.display()
1889            ),
1890            PluginSelectionStatus::Exact => {
1891                anyhow::anyhow!(
1892                    "Incompatible graph at {} (no detail); rerun `sqry index --force`",
1893                    source_root.display()
1894                )
1895            }
1896            other => anyhow::anyhow!(
1897                "Incompatible graph at {}: {other:?}. Rebuild with `sqry index {} --force`.",
1898                source_root.display(),
1899                source_root.display(),
1900            ),
1901        },
1902        GraphAcquisitionError::LoadFailed {
1903            source_root,
1904            reason,
1905        } => anyhow::anyhow!(
1906            "Failed to load graph at {}: {}",
1907            source_root.display(),
1908            reason
1909        ),
1910        other => anyhow::anyhow!("graph acquisition failed: {other}"),
1911    }
1912}
1913
1914fn u64_to_f64_lossy(value: u64) -> f64 {
1915    let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
1916    f64::from(narrowed)
1917}
1918
1919// ============================================================================
1920// Variable, Join, and Pipeline support
1921// ============================================================================
1922
1923/// Parse `--var KEY=VALUE` arguments into a `HashMap`.
1924fn parse_variable_args(args: &[String]) -> Result<std::collections::HashMap<String, String>> {
1925    let mut map = std::collections::HashMap::new();
1926    for arg in args {
1927        let (key, value) = arg
1928            .split_once('=')
1929            .ok_or_else(|| anyhow::anyhow!("Invalid --var format: '{arg}'. Expected KEY=VALUE"))?;
1930        if key.is_empty() {
1931            bail!("Variable name cannot be empty in --var '{arg}'");
1932        }
1933        map.insert(key.to_string(), value.to_string());
1934    }
1935    Ok(map)
1936}
1937
1938/// Check if a query string contains a join expression at the root level.
1939///
1940/// Returns `false` on parse errors (the normal flow will handle the error).
1941fn is_join_query(query_str: &str) -> bool {
1942    match QueryParser::parse_query(query_str) {
1943        Ok(ast) => matches!(ast.root, Expr::Join(_)),
1944        Err(_) => false,
1945    }
1946}
1947
1948/// Detect a pipeline query (base query | aggregation stages).
1949///
1950/// If the query string contains a `|` character, pipeline parse errors are
1951/// treated as hard errors (the user intended a pipeline query). If no `|`
1952/// is present, returns `None` (not a pipeline query).
1953fn detect_pipeline_query(
1954    query_str: &str,
1955) -> Result<Option<sqry_core::query::types::PipelineQuery>> {
1956    match QueryParser::parse_pipeline_query(query_str) {
1957        Ok(result) => Ok(result),
1958        Err(e) => {
1959            // If the query contains a pipe, the user intended a pipeline query
1960            // and the parse error should be surfaced (not silently ignored).
1961            if query_str.contains('|') {
1962                Err(anyhow::anyhow!("Pipeline parse error: {e}"))
1963            } else {
1964                Ok(None)
1965            }
1966        }
1967    }
1968}
1969
1970/// Run a join query and render results.
1971fn run_join_query(
1972    cli: &Cli,
1973    streams: &mut OutputStreams,
1974    query_string: &str,
1975    search_path: &str,
1976    no_parallel: bool,
1977    variables: Option<&std::collections::HashMap<String, String>>,
1978) -> Result<()> {
1979    let validation_options = build_validation_options(cli);
1980    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
1981        .with_validation_options(validation_options);
1982    if no_parallel {
1983        executor = executor.without_parallel();
1984    }
1985
1986    let resolved_path = Path::new(search_path);
1987    let join_results = executor.execute_join(query_string, resolved_path, variables)?;
1988
1989    if join_results.truncated() {
1990        streams.write_diagnostic(&format!(
1991            "Join query: {} pairs matched via {} (results truncated — cap reached)",
1992            join_results.len(),
1993            join_results.edge_kind()
1994        ))?;
1995    } else {
1996        streams.write_diagnostic(&format!(
1997            "Join query: {} pairs matched via {}",
1998            join_results.len(),
1999            join_results.edge_kind()
2000        ))?;
2001    }
2002
2003    for pair in join_results.iter() {
2004        let left_name = pair.left.name().unwrap_or_default();
2005        let left_path = pair
2006            .left
2007            .relative_path()
2008            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
2009        let right_name = pair.right.name().unwrap_or_default();
2010        let right_path = pair
2011            .right
2012            .relative_path()
2013            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
2014
2015        if cli.json {
2016            // JSON mode: each pair as a JSON object
2017            let json = serde_json::json!({
2018                "left": {
2019                    "name": left_name.as_ref(),
2020                    "kind": pair.left.kind().as_str(),
2021                    "path": left_path,
2022                    "line": pair.left.start_line(),
2023                },
2024                "edge": pair.edge_kind.to_string(),
2025                "right": {
2026                    "name": right_name.as_ref(),
2027                    "kind": pair.right.kind().as_str(),
2028                    "path": right_path,
2029                    "line": pair.right.start_line(),
2030                },
2031            });
2032            streams.write_result(&json.to_string())?;
2033        } else {
2034            streams.write_result(&format!(
2035                "{} ({}:{}) {} {} ({}:{})",
2036                left_name,
2037                left_path,
2038                pair.left.start_line(),
2039                pair.edge_kind,
2040                right_name,
2041                right_path,
2042                pair.right.start_line(),
2043            ))?;
2044        }
2045    }
2046
2047    Ok(())
2048}
2049
2050/// Run a pipeline query (base query + aggregation stages) and render results.
2051fn run_pipeline_query(
2052    cli: &Cli,
2053    streams: &mut OutputStreams,
2054    _query_string: &str,
2055    search_path: &str,
2056    pipeline: &sqry_core::query::types::PipelineQuery,
2057    no_parallel: bool,
2058    variables: Option<&std::collections::HashMap<String, String>>,
2059) -> Result<()> {
2060    let validation_options = build_validation_options(cli);
2061    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
2062        .with_validation_options(validation_options);
2063    if no_parallel {
2064        executor = executor.without_parallel();
2065    }
2066
2067    let resolved_path = Path::new(search_path);
2068
2069    // Execute the base query portion (before the pipe)
2070    // Serialize the base query from the parsed AST for reliable reconstruction
2071    let base_query = sqry_core::query::parsed_query::serialize_query(&pipeline.query);
2072
2073    let results =
2074        executor.execute_on_graph_with_variables(&base_query, resolved_path, variables)?;
2075
2076    // Execute each pipeline stage
2077    for stage in &pipeline.stages {
2078        let aggregation = sqry_core::query::execute_pipeline_stage(&results, stage);
2079
2080        if cli.json {
2081            render_aggregation_json(streams, &aggregation)?;
2082        } else {
2083            streams.write_result(&format!("{aggregation}"))?;
2084        }
2085    }
2086
2087    Ok(())
2088}
2089
2090/// Render aggregation results as JSON.
2091fn render_aggregation_json(
2092    streams: &mut OutputStreams,
2093    aggregation: &sqry_core::query::pipeline::AggregationResult,
2094) -> Result<()> {
2095    use sqry_core::query::pipeline::AggregationResult;
2096    let json = match aggregation {
2097        AggregationResult::Count(r) => serde_json::json!({
2098            "type": "count",
2099            "total": r.total,
2100        }),
2101        AggregationResult::GroupBy(r) => serde_json::json!({
2102            "type": "group_by",
2103            "field": r.field,
2104            "groups": r.groups.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2105        }),
2106        AggregationResult::Top(r) => serde_json::json!({
2107            "type": "top",
2108            "field": r.field,
2109            "n": r.n,
2110            "entries": r.entries.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2111        }),
2112        AggregationResult::Stats(r) => serde_json::json!({
2113            "type": "stats",
2114            "total": r.total,
2115            "by_kind": r.by_kind.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2116            "by_lang": r.by_lang.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2117            "by_visibility": r.by_visibility.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2118        }),
2119    };
2120    streams.write_result(&json.to_string())?;
2121    Ok(())
2122}
2123
2124#[cfg(test)]
2125mod tests {
2126    use super::*;
2127    use sqry_core::relations::CallIdentityKind;
2128
2129    // ==========================================================================
2130    // u64_to_f64_lossy tests
2131    // ==========================================================================
2132
2133    #[test]
2134    fn test_u64_to_f64_lossy_zero() {
2135        assert!((u64_to_f64_lossy(0) - 0.0).abs() < f64::EPSILON);
2136    }
2137
2138    #[test]
2139    fn test_u64_to_f64_lossy_small_values() {
2140        assert!((u64_to_f64_lossy(1) - 1.0).abs() < f64::EPSILON);
2141        assert!((u64_to_f64_lossy(100) - 100.0).abs() < f64::EPSILON);
2142        assert!((u64_to_f64_lossy(1000) - 1000.0).abs() < f64::EPSILON);
2143    }
2144
2145    #[test]
2146    fn test_u64_to_f64_lossy_u32_max() {
2147        let u32_max = u64::from(u32::MAX);
2148        assert!((u64_to_f64_lossy(u32_max) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2149    }
2150
2151    #[test]
2152    fn test_u64_to_f64_lossy_overflow_clamps_to_u32_max() {
2153        // Values larger than u32::MAX should clamp
2154        let large_value = u64::from(u32::MAX) + 1;
2155        assert!((u64_to_f64_lossy(large_value) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2156    }
2157
2158    // ==========================================================================
2159    // format_cache_status tests
2160    // ==========================================================================
2161
2162    #[test]
2163    fn test_format_cache_status_full_hit() {
2164        let status = sqry_core::query::CacheStatus {
2165            parse_cache_hit: true,
2166            result_cache_hit: true,
2167        };
2168        assert_eq!(format_cache_status(&status), "HIT (100% cached)");
2169    }
2170
2171    #[test]
2172    fn test_format_cache_status_parse_hit_only() {
2173        let status = sqry_core::query::CacheStatus {
2174            parse_cache_hit: true,
2175            result_cache_hit: false,
2176        };
2177        assert_eq!(
2178            format_cache_status(&status),
2179            "PARTIAL HIT (query cached, results computed)"
2180        );
2181    }
2182
2183    #[test]
2184    fn test_format_cache_status_result_hit_only() {
2185        let status = sqry_core::query::CacheStatus {
2186            parse_cache_hit: false,
2187            result_cache_hit: true,
2188        };
2189        assert_eq!(
2190            format_cache_status(&status),
2191            "PARTIAL HIT (query parsed, results cached)"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_format_cache_status_full_miss() {
2197        let status = sqry_core::query::CacheStatus {
2198            parse_cache_hit: false,
2199            result_cache_hit: false,
2200        };
2201        assert_eq!(format_cache_status(&status), "MISS (first run)");
2202    }
2203
2204    // ==========================================================================
2205    // format_execution_steps tests
2206    // ==========================================================================
2207
2208    #[test]
2209    fn test_format_execution_steps_empty() {
2210        let steps: Vec<sqry_core::query::ExecutionStep> = vec![];
2211        assert_eq!(format_execution_steps(&steps), "");
2212    }
2213
2214    #[test]
2215    fn test_format_execution_steps_single() {
2216        let steps = vec![sqry_core::query::ExecutionStep {
2217            step_num: 1,
2218            operation: "Parse query".to_string(),
2219            result_count: 0,
2220            time_ms: 5,
2221        }];
2222        assert_eq!(format_execution_steps(&steps), "  1. Parse query (5ms)");
2223    }
2224
2225    #[test]
2226    fn test_format_execution_steps_multiple() {
2227        let steps = vec![
2228            sqry_core::query::ExecutionStep {
2229                step_num: 1,
2230                operation: "Parse".to_string(),
2231                result_count: 100,
2232                time_ms: 2,
2233            },
2234            sqry_core::query::ExecutionStep {
2235                step_num: 2,
2236                operation: "Optimize".to_string(),
2237                result_count: 50,
2238                time_ms: 3,
2239            },
2240            sqry_core::query::ExecutionStep {
2241                step_num: 3,
2242                operation: "Execute".to_string(),
2243                result_count: 25,
2244                time_ms: 10,
2245            },
2246        ];
2247        let expected = "  1. Parse (2ms)\n  2. Optimize (3ms)\n  3. Execute (10ms)";
2248        assert_eq!(format_execution_steps(&steps), expected);
2249    }
2250
2251    // ==========================================================================
2252    // expr_has_repo_predicate tests
2253    // ==========================================================================
2254
2255    #[test]
2256    fn test_expr_has_repo_predicate_simple_repo() {
2257        let query = QueryParser::parse_query("repo:myrepo").unwrap();
2258        assert!(expr_has_repo_predicate(&query.root));
2259    }
2260
2261    #[test]
2262    fn test_expr_has_repo_predicate_no_repo() {
2263        let query = QueryParser::parse_query("kind:function").unwrap();
2264        assert!(!expr_has_repo_predicate(&query.root));
2265    }
2266
2267    #[test]
2268    fn test_expr_has_repo_predicate_nested_and() {
2269        let query = QueryParser::parse_query("kind:function AND repo:myrepo").unwrap();
2270        assert!(expr_has_repo_predicate(&query.root));
2271    }
2272
2273    #[test]
2274    fn test_expr_has_repo_predicate_nested_or() {
2275        let query = QueryParser::parse_query("kind:function OR repo:myrepo").unwrap();
2276        assert!(expr_has_repo_predicate(&query.root));
2277    }
2278
2279    #[test]
2280    fn test_expr_has_repo_predicate_nested_not() {
2281        let query = QueryParser::parse_query("NOT repo:myrepo").unwrap();
2282        assert!(expr_has_repo_predicate(&query.root));
2283    }
2284
2285    #[test]
2286    fn test_expr_has_repo_predicate_complex_no_repo() {
2287        let query = QueryParser::parse_query("kind:function AND name:foo OR lang:rust").unwrap();
2288        assert!(!expr_has_repo_predicate(&query.root));
2289    }
2290
2291    // ==========================================================================
2292    // RelationDisplayContext tests
2293    // ==========================================================================
2294
2295    #[test]
2296    fn test_relation_context_no_relations() {
2297        let ctx = RelationDisplayContext::from_query("kind:function");
2298        assert!(ctx.caller_targets.is_empty());
2299        assert!(ctx.callee_targets.is_empty());
2300    }
2301
2302    #[test]
2303    fn test_relation_context_with_callers() {
2304        let ctx = RelationDisplayContext::from_query("callers:foo");
2305        assert_eq!(ctx.caller_targets, vec!["foo"]);
2306        assert!(ctx.callee_targets.is_empty());
2307    }
2308
2309    #[test]
2310    fn test_relation_context_with_callees() {
2311        let ctx = RelationDisplayContext::from_query("callees:bar");
2312        assert!(ctx.caller_targets.is_empty());
2313        assert_eq!(ctx.callee_targets, vec!["bar"]);
2314    }
2315
2316    #[test]
2317    fn test_relation_context_with_both() {
2318        let ctx = RelationDisplayContext::from_query("callers:foo AND callees:bar");
2319        assert_eq!(ctx.caller_targets, vec!["foo"]);
2320        assert_eq!(ctx.callee_targets, vec!["bar"]);
2321    }
2322
2323    #[test]
2324    fn test_relation_context_invalid_query() {
2325        // Invalid queries should return default context
2326        let ctx = RelationDisplayContext::from_query("invalid query syntax ???");
2327        assert!(ctx.caller_targets.is_empty());
2328        assert!(ctx.callee_targets.is_empty());
2329    }
2330
2331    #[test]
2332    fn test_build_identity_from_qualified_name_preserves_ruby_instance_display() {
2333        let identity = build_identity_from_qualified_name(
2334            "Admin::Users::Controller::show",
2335            "method",
2336            Some("ruby"),
2337            false,
2338        )
2339        .expect("ruby instance identity");
2340
2341        assert_eq!(identity.qualified, "Admin::Users::Controller#show");
2342        assert_eq!(identity.method_kind, CallIdentityKind::Instance);
2343    }
2344
2345    #[test]
2346    fn test_build_identity_from_qualified_name_preserves_ruby_singleton_display() {
2347        let identity = build_identity_from_qualified_name(
2348            "Admin::Users::Controller::show",
2349            "method",
2350            Some("ruby"),
2351            true,
2352        )
2353        .expect("ruby singleton identity");
2354
2355        assert_eq!(identity.qualified, "Admin::Users::Controller.show");
2356        assert_eq!(identity.method_kind, CallIdentityKind::Singleton);
2357    }
2358
2359    // ==========================================================================
2360    // ensure_repo_predicate_not_present tests
2361    // ==========================================================================
2362
2363    #[test]
2364    fn test_ensure_repo_not_present_ok() {
2365        let result = ensure_repo_predicate_not_present("kind:function");
2366        assert!(result.is_ok());
2367    }
2368
2369    #[test]
2370    fn test_ensure_repo_not_present_fails_with_repo() {
2371        let result = ensure_repo_predicate_not_present("repo:myrepo");
2372        assert!(result.is_err());
2373        assert!(
2374            result
2375                .unwrap_err()
2376                .to_string()
2377                .contains("repo: filters are only supported")
2378        );
2379    }
2380
2381    #[test]
2382    fn test_ensure_repo_not_present_fails_with_nested_repo() {
2383        let result = ensure_repo_predicate_not_present("kind:function AND repo:myrepo");
2384        assert!(result.is_err());
2385    }
2386
2387    #[test]
2388    fn test_ensure_repo_not_present_fallback_text_check() {
2389        // Even if query doesn't parse, text-based check should work
2390        let result = ensure_repo_predicate_not_present("invalid??? repo:something");
2391        assert!(result.is_err());
2392    }
2393
2394    // ==========================================================================
2395    // parse_variable_args tests
2396    // ==========================================================================
2397
2398    #[test]
2399    fn test_parse_variable_args_empty() {
2400        let result = parse_variable_args(&[]).unwrap();
2401        assert!(result.is_empty());
2402    }
2403
2404    #[test]
2405    fn test_parse_variable_args_single_key_value() {
2406        let args = vec!["FOO=bar".to_string()];
2407        let result = parse_variable_args(&args).unwrap();
2408        assert_eq!(result.len(), 1);
2409        assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
2410    }
2411
2412    #[test]
2413    fn test_parse_variable_args_multiple() {
2414        let args = vec!["A=1".to_string(), "B=hello world".to_string()];
2415        let result = parse_variable_args(&args).unwrap();
2416        assert_eq!(result.len(), 2);
2417        assert_eq!(result.get("A"), Some(&"1".to_string()));
2418        assert_eq!(result.get("B"), Some(&"hello world".to_string()));
2419    }
2420
2421    #[test]
2422    fn test_parse_variable_args_value_with_equals() {
2423        // Only the first '=' is the separator; rest is the value
2424        let args = vec!["KEY=val=ue".to_string()];
2425        let result = parse_variable_args(&args).unwrap();
2426        assert_eq!(result.get("KEY"), Some(&"val=ue".to_string()));
2427    }
2428
2429    #[test]
2430    fn test_parse_variable_args_no_equals_errors() {
2431        let args = vec!["NOEQUALS".to_string()];
2432        let err = parse_variable_args(&args).unwrap_err();
2433        assert!(
2434            err.to_string().contains("Invalid --var format"),
2435            "Unexpected error: {err}"
2436        );
2437    }
2438
2439    #[test]
2440    fn test_parse_variable_args_empty_key_errors() {
2441        let args = vec!["=value".to_string()];
2442        let err = parse_variable_args(&args).unwrap_err();
2443        assert!(
2444            err.to_string().contains("Variable name cannot be empty"),
2445            "Unexpected error: {err}"
2446        );
2447    }
2448
2449    #[test]
2450    fn test_parse_variable_args_empty_value_allowed() {
2451        let args = vec!["KEY=".to_string()];
2452        let result = parse_variable_args(&args).unwrap();
2453        assert_eq!(result.get("KEY"), Some(&String::new()));
2454    }
2455
2456    // ==========================================================================
2457    // is_join_query tests
2458    // ==========================================================================
2459
2460    #[test]
2461    fn test_is_join_query_non_join() {
2462        assert!(!is_join_query("kind:function"));
2463        assert!(!is_join_query("name:foo AND kind:method"));
2464    }
2465
2466    #[test]
2467    fn test_is_join_query_invalid_query_returns_false() {
2468        // parse errors → false, not panic
2469        assert!(!is_join_query("invalid ??? syntax {{{"));
2470    }
2471
2472    #[test]
2473    fn test_is_join_query_positive() {
2474        // A valid join expression uses the CALLS operator between two sub-queries.
2475        // The parser recognises `(lhs) CALLS (rhs)` as a Join expression.
2476        assert!(
2477            is_join_query("(kind:function) CALLS (kind:function)"),
2478            "CALLS join expression must be detected as a join query"
2479        );
2480    }
2481
2482    // ==========================================================================
2483    // detect_pipeline_query tests
2484    // ==========================================================================
2485
2486    #[test]
2487    fn test_detect_pipeline_query_no_pipe_returns_none() {
2488        let result = detect_pipeline_query("kind:function").unwrap();
2489        assert!(result.is_none());
2490    }
2491
2492    #[test]
2493    fn test_detect_pipeline_query_invalid_without_pipe_returns_none() {
2494        // No pipe → even parse errors silently return None
2495        let result = detect_pipeline_query("invalid query !!!").unwrap();
2496        assert!(result.is_none());
2497    }
2498
2499    #[test]
2500    fn test_detect_pipeline_query_invalid_with_pipe_errors() {
2501        // A well-formed pipeline query (base `|` valid stage) must return Ok.
2502        // The presence of `|` only turns parse *errors* into hard errors; a
2503        // successful parse must always return Ok(Some(_)).
2504        let result = detect_pipeline_query("kind:function | count");
2505        assert!(
2506            result.is_ok(),
2507            "A valid pipeline query must return Ok, got: {result:?}"
2508        );
2509        assert!(
2510            result.unwrap().is_some(),
2511            "A valid pipeline query must return Ok(Some(_))"
2512        );
2513    }
2514
2515    // ==========================================================================
2516    // apply_symbol_limit tests
2517    // ==========================================================================
2518
2519    #[test]
2520    fn test_apply_symbol_limit_no_truncation() {
2521        let mut symbols: Vec<DisplaySymbol> = (0..5)
2522            .map(|i| DisplaySymbol {
2523                name: format!("sym{i}"),
2524                qualified_name: format!("sym{i}"),
2525                kind: "function".to_string(),
2526                file_path: std::path::PathBuf::from("a.rs"),
2527                start_line: i,
2528                start_column: 0,
2529                end_line: i,
2530                end_column: 0,
2531                metadata: std::collections::HashMap::new(),
2532                caller_identity: None,
2533                callee_identity: None,
2534            })
2535            .collect();
2536
2537        let info = apply_symbol_limit(&mut symbols, 10);
2538        assert_eq!(symbols.len(), 5);
2539        assert!(!info.truncated);
2540        assert_eq!(info.total_matches, 5);
2541        assert_eq!(info.limit, 10);
2542    }
2543
2544    #[test]
2545    fn test_apply_symbol_limit_truncates() {
2546        let mut symbols: Vec<DisplaySymbol> = (0..20)
2547            .map(|i| DisplaySymbol {
2548                name: format!("sym{i}"),
2549                qualified_name: format!("sym{i}"),
2550                kind: "function".to_string(),
2551                file_path: std::path::PathBuf::from("a.rs"),
2552                start_line: i,
2553                start_column: 0,
2554                end_line: i,
2555                end_column: 0,
2556                metadata: std::collections::HashMap::new(),
2557                caller_identity: None,
2558                callee_identity: None,
2559            })
2560            .collect();
2561
2562        let info = apply_symbol_limit(&mut symbols, 5);
2563        assert_eq!(symbols.len(), 5);
2564        assert!(info.truncated);
2565        assert_eq!(info.total_matches, 20);
2566        assert_eq!(info.limit, 5);
2567    }
2568
2569    #[test]
2570    fn test_apply_symbol_limit_exact_boundary() {
2571        let mut symbols: Vec<DisplaySymbol> = (0..5)
2572            .map(|i| DisplaySymbol {
2573                name: format!("sym{i}"),
2574                qualified_name: format!("sym{i}"),
2575                kind: "function".to_string(),
2576                file_path: std::path::PathBuf::from("a.rs"),
2577                start_line: i,
2578                start_column: 0,
2579                end_line: i,
2580                end_column: 0,
2581                metadata: std::collections::HashMap::new(),
2582                caller_identity: None,
2583                callee_identity: None,
2584            })
2585            .collect();
2586
2587        let info = apply_symbol_limit(&mut symbols, 5);
2588        assert_eq!(symbols.len(), 5);
2589        assert!(!info.truncated, "Exact boundary should not truncate");
2590    }
2591
2592    // ==========================================================================
2593    // u64_to_f64_lossy additional edge cases
2594    // ==========================================================================
2595
2596    #[test]
2597    fn test_u64_to_f64_lossy_large_values_clamp_to_u32_max() {
2598        let very_large = u64::MAX;
2599        let result = u64_to_f64_lossy(very_large);
2600        // Should clamp to u32::MAX
2601        assert!((result - f64::from(u32::MAX)).abs() < f64::EPSILON);
2602    }
2603
2604    // ==========================================================================
2605    // env_debug_cache_enabled tests
2606    // ==========================================================================
2607
2608    #[serial_test::serial]
2609    #[test]
2610    fn test_env_debug_cache_disabled_by_default() {
2611        // In a clean test environment, SQRY_CACHE_DEBUG should not be set
2612        // (if it is set externally, we skip this test)
2613        unsafe {
2614            std::env::remove_var("SQRY_CACHE_DEBUG");
2615        }
2616        assert!(!env_debug_cache_enabled());
2617    }
2618
2619    #[serial_test::serial]
2620    #[test]
2621    fn test_env_debug_cache_enabled_with_1() {
2622        unsafe {
2623            std::env::set_var("SQRY_CACHE_DEBUG", "1");
2624        }
2625        let result = env_debug_cache_enabled();
2626        unsafe {
2627            std::env::remove_var("SQRY_CACHE_DEBUG");
2628        }
2629        assert!(result);
2630    }
2631
2632    #[serial_test::serial]
2633    #[test]
2634    fn test_env_debug_cache_enabled_with_true() {
2635        unsafe {
2636            std::env::set_var("SQRY_CACHE_DEBUG", "true");
2637        }
2638        let result = env_debug_cache_enabled();
2639        unsafe {
2640            std::env::remove_var("SQRY_CACHE_DEBUG");
2641        }
2642        assert!(result);
2643    }
2644
2645    #[serial_test::serial]
2646    #[test]
2647    fn test_env_debug_cache_enabled_with_true_uppercase() {
2648        unsafe {
2649            std::env::set_var("SQRY_CACHE_DEBUG", "TRUE");
2650        }
2651        let result = env_debug_cache_enabled();
2652        unsafe {
2653            std::env::remove_var("SQRY_CACHE_DEBUG");
2654        }
2655        assert!(result);
2656    }
2657
2658    #[serial_test::serial]
2659    #[test]
2660    fn test_env_debug_cache_disabled_with_zero() {
2661        unsafe {
2662            std::env::set_var("SQRY_CACHE_DEBUG", "0");
2663        }
2664        let result = env_debug_cache_enabled();
2665        unsafe {
2666            std::env::remove_var("SQRY_CACHE_DEBUG");
2667        }
2668        assert!(!result);
2669    }
2670
2671    // ==========================================================================
2672    // build_query_stats tests
2673    // ==========================================================================
2674
2675    #[test]
2676    fn test_build_query_stats_with_index() {
2677        let stats = build_query_stats(true, 10);
2678        assert!(stats.used_index);
2679    }
2680
2681    #[test]
2682    fn test_build_query_stats_without_index() {
2683        let stats = build_query_stats(false, 10);
2684        assert!(!stats.used_index);
2685    }
2686}