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::query::QueryExecutor;
12use sqry_core::query::parser_new::Parser as QueryParser;
13use sqry_core::query::results::QueryResults;
14use sqry_core::query::security::QuerySecurityConfig;
15use sqry_core::query::types::{Expr, Value};
16use sqry_core::query::validator::ValidationOptions;
17use sqry_core::relations::CallIdentityMetadata;
18use sqry_core::search::Match as TextMatch;
19use sqry_core::search::classifier::{QueryClassifier, QueryType};
20use sqry_core::search::fallback::{FallbackConfig, FallbackSearchEngine, SearchResults};
21use sqry_core::session::{SessionManager, SessionStats};
22use std::env;
23use std::path::{Path, PathBuf};
24use std::sync::Mutex;
25use std::time::{Duration, Instant};
26
27static QUERY_SESSION: std::sync::LazyLock<Mutex<Option<SessionManager>>> =
28    std::sync::LazyLock::new(|| Mutex::new(None));
29
30const DEFAULT_QUERY_LIMIT: usize = 1000;
31
32/// Simple query statistics for CLI display (replaces `sqry_core::query::QueryStats`).
33#[derive(Debug, Clone, Default)]
34struct SimpleQueryStats {
35    /// Whether a graph/index was used
36    used_index: bool,
37}
38
39/// Convert `QueryResults` to `Vec<DisplaySymbol>` for display purposes.
40///
41/// This creates `DisplaySymbol` structs directly from `QueryMatch`,
42/// avoiding the deprecated Symbol intermediate type.
43fn query_results_to_display_symbols(results: &QueryResults) -> Vec<DisplaySymbol> {
44    results
45        .iter()
46        .map(|m| DisplaySymbol::from_query_match(&m))
47        .collect()
48}
49
50struct QueryExecution {
51    stats: SimpleQueryStats,
52    symbols: Vec<DisplaySymbol>,
53    executor: Option<QueryExecutor>,
54}
55
56enum QueryExecutionOutcome {
57    Terminal,
58    Continue(Box<QueryExecution>),
59}
60
61struct NonSessionQueryParams<'a> {
62    cli: &'a Cli,
63    query_string: &'a str,
64    search_path: &'a str,
65    validation_options: ValidationOptions,
66    verbose: bool,
67    no_parallel: bool,
68    relation_context: &'a RelationDisplayContext,
69    variables: Option<&'a std::collections::HashMap<String, String>>,
70}
71
72struct QueryExecutionParams<'a> {
73    cli: &'a Cli,
74    query_string: &'a str,
75    search_path: &'a Path,
76    validation_options: ValidationOptions,
77    no_parallel: bool,
78    start: Instant,
79    query_type: QueryType,
80    variables: Option<&'a std::collections::HashMap<String, String>>,
81}
82
83struct QueryRenderParams<'a> {
84    cli: &'a Cli,
85    query_string: &'a str,
86    verbose: bool,
87    start: Instant,
88    relation_context: &'a RelationDisplayContext,
89    index_info: IndexDiagnosticInfo,
90}
91
92struct HybridQueryParams<'a> {
93    cli: &'a Cli,
94    query_string: &'a str,
95    search_path: &'a Path,
96    validation_options: ValidationOptions,
97    no_parallel: bool,
98    start: Instant,
99    query_type: QueryType,
100    variables: Option<&'a std::collections::HashMap<String, String>>,
101}
102
103/// Run a query command to search for symbols using AST-aware predicates
104///
105/// # Arguments
106///
107/// * `cli` - CLI arguments
108/// * `query_string` - Query string with predicates (e.g., "kind:function AND name~=/test/")
109/// * `search_path` - Path to search (file or directory)
110/// * `explain` - If true, explain the query instead of executing it
111/// * `verbose` - If true, show verbose output including cache statistics
112/// * `session_mode` - If true, use persistent session for repeated queries
113/// * `no_parallel` - If true, disable parallel query execution (for A/B testing)
114/// * `timeout_secs` - Query timeout in seconds (max 30s per security policy)
115/// * `result_limit` - Maximum number of results to return
116///
117/// # Errors
118/// Returns an error if query validation fails, execution fails, or output cannot be written.
119#[allow(clippy::too_many_arguments)]
120#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
121pub fn run_query(
122    cli: &Cli,
123    query_string: &str,
124    search_path: &str,
125    explain: bool,
126    verbose: bool,
127    session_mode: bool,
128    no_parallel: bool,
129    timeout_secs: Option<u64>,
130    result_limit: Option<usize>,
131    variables: &[String],
132) -> Result<()> {
133    // Create output streams with optional pager support
134    let mut streams = OutputStreams::with_pager(cli.pager_config());
135
136    ensure_repo_predicate_not_present(query_string)?;
137
138    let validation_options = build_validation_options(cli);
139
140    // Build security config from CLI flags (30s ceiling is enforced by QuerySecurityConfig)
141    let security_config = build_security_config(timeout_secs, result_limit);
142    maybe_emit_security_diagnostics(&mut streams, &security_config, verbose)?;
143
144    // NOTE: Security enforcement via QueryGuard will be integrated into QueryExecutor
145    // in a future enhancement. For now, the config is built and validated.
146    let _ = &security_config; // Silence unused warning until full integration
147
148    // Parse --var KEY=VALUE pairs into a variables map for the executor
149    let parsed_variables = parse_variable_args(variables)?;
150    let variables_opt = if parsed_variables.is_empty() {
151        None
152    } else {
153        Some(&parsed_variables)
154    };
155
156    // Check for pipeline queries (base query | stage)
157    if let Some(pipeline) = detect_pipeline_query(query_string)? {
158        run_pipeline_query(
159            cli,
160            &mut streams,
161            query_string,
162            search_path,
163            &pipeline,
164            no_parallel,
165            variables_opt,
166        )?;
167        return streams.finish_checked();
168    }
169
170    // Check for join queries (LHS CALLS RHS)
171    if is_join_query(query_string) {
172        run_join_query(
173            cli,
174            &mut streams,
175            query_string,
176            search_path,
177            no_parallel,
178            variables_opt,
179        )?;
180        return streams.finish_checked();
181    }
182
183    // If explain mode, use get_query_plan for detailed output (semantic only)
184    if explain {
185        run_query_explain(query_string, validation_options, no_parallel, &mut streams)?;
186        return streams.finish_checked();
187    }
188
189    let relation_context = RelationDisplayContext::from_query(query_string);
190
191    // IMPORTANT: Check session mode FIRST, before any index loading
192    // This allows session queries to short-circuit directly to the cached executor
193    // (fixes CODEX MEDIUM-2: session mode was validating before checking cache)
194    // RKG: CODE:SQRY-CLI implements REQ:SQRY-RUBY-QUALIFIED-CALLERS (MEDIUM-2 fix)
195    if session_mode {
196        let result = run_query_with_session(
197            cli,
198            &mut streams,
199            query_string,
200            search_path,
201            verbose,
202            no_parallel,
203            &relation_context,
204        );
205        // Check result first, then finalize pager
206        // If the query failed, return that error; otherwise check pager status
207        result?;
208        return streams.finish_checked();
209    }
210
211    let params = NonSessionQueryParams {
212        cli,
213        query_string,
214        search_path,
215        validation_options,
216        verbose,
217        no_parallel,
218        relation_context: &relation_context,
219        variables: variables_opt,
220    };
221    run_query_non_session(&mut streams, &params)?;
222
223    // Finalize pager (flushes buffer, waits for pager if spawned, propagates exit code)
224    streams.finish_checked()
225}
226
227fn build_validation_options(cli: &Cli) -> ValidationOptions {
228    ValidationOptions {
229        fuzzy_fields: cli.fuzzy_fields,
230        fuzzy_field_distance: cli.fuzzy_field_distance,
231    }
232}
233
234fn build_security_config(
235    timeout_secs: Option<u64>,
236    result_limit: Option<usize>,
237) -> QuerySecurityConfig {
238    let mut config = QuerySecurityConfig::default();
239    if let Some(secs) = timeout_secs {
240        config = config.with_timeout(Duration::from_secs(secs));
241    }
242    if let Some(limit) = result_limit {
243        config = config.with_result_cap(limit);
244    }
245    config
246}
247
248fn maybe_emit_security_diagnostics(
249    streams: &mut OutputStreams,
250    security_config: &QuerySecurityConfig,
251    verbose: bool,
252) -> Result<()> {
253    if verbose {
254        streams.write_diagnostic(&format!(
255            "[Security] timeout={}s, limit={}, memory={}MB",
256            security_config.timeout().as_secs(),
257            security_config.result_cap(),
258            security_config.memory_limit() / (1024 * 1024),
259        ))?;
260    }
261    Ok(())
262}
263
264fn run_query_explain(
265    query_string: &str,
266    validation_options: ValidationOptions,
267    no_parallel: bool,
268    streams: &mut OutputStreams,
269) -> Result<()> {
270    let mut executor = create_executor_with_plugins().with_validation_options(validation_options);
271    if no_parallel {
272        executor = executor.without_parallel();
273    }
274    let plan = executor.get_query_plan(query_string)?;
275    let explain_output = format!(
276        "Query Plan:\n  Original: {}\n  Optimized: {}\n\nExecution:\n{}\n\nPerformance:\n  Execution time: {}ms\n  Index-aware: {}\n  Cache: {}",
277        plan.original_query,
278        plan.optimized_query,
279        format_execution_steps(&plan.steps),
280        plan.execution_time_ms,
281        if plan.used_index { "Yes" } else { "No" },
282        format_cache_status(&plan.cache_status),
283    );
284    streams.write_diagnostic(&explain_output)?;
285    Ok(())
286}
287
288/// Resolved effective index root, augmented query, and diagnostic info.
289struct EffectiveIndexResolution {
290    index_root: PathBuf,
291    query: String,
292    info: IndexDiagnosticInfo,
293}
294
295/// Walk up the directory tree to find the nearest index, determine the effective
296/// index root, augment the query with scope filters if needed, and build diagnostic info.
297fn resolve_effective_index_root(
298    search_path: &Path,
299    query_string: &str,
300) -> EffectiveIndexResolution {
301    let index_location = find_nearest_index(search_path);
302
303    if let Some(ref loc) = index_location {
304        let root = loc.index_root.clone();
305        let (query, filtered_to) = if loc.requires_scope_filter {
306            if let Some(relative_scope) = loc.relative_scope() {
307                let scope_str = if loc.is_file_query {
308                    relative_scope.to_string_lossy().into_owned()
309                } else {
310                    format!("{}/**", relative_scope.display())
311                };
312                let augmented =
313                    augment_query_with_scope(query_string, &relative_scope, loc.is_file_query);
314                (augmented, Some(scope_str))
315            } else {
316                (query_string.to_string(), None)
317            }
318        } else {
319            (query_string.to_string(), None)
320        };
321        let info = IndexDiagnosticInfo {
322            index_root: Some(root.clone()),
323            filtered_to,
324            used_ancestor_index: loc.is_ancestor,
325        };
326        EffectiveIndexResolution {
327            index_root: root,
328            query,
329            info,
330        }
331    } else {
332        EffectiveIndexResolution {
333            index_root: search_path.to_path_buf(),
334            query: query_string.to_string(),
335            info: IndexDiagnosticInfo::default(),
336        }
337    }
338}
339
340fn run_query_non_session(
341    streams: &mut OutputStreams,
342    params: &NonSessionQueryParams<'_>,
343) -> Result<()> {
344    let NonSessionQueryParams {
345        cli,
346        query_string,
347        search_path,
348        validation_options,
349        verbose,
350        no_parallel,
351        relation_context,
352        variables,
353    } = *params;
354    let search_path_path = Path::new(search_path);
355
356    // Index ancestor discovery: find nearest .sqry-index in directory tree
357    let resolution = resolve_effective_index_root(search_path_path, query_string);
358    let EffectiveIndexResolution {
359        index_root: effective_index_root,
360        query: effective_query,
361        info: index_info,
362    } = resolution;
363
364    let query_type = QueryClassifier::classify(&effective_query);
365
366    let start = Instant::now();
367    let execution_params = QueryExecutionParams {
368        cli,
369        query_string: &effective_query,
370        search_path: &effective_index_root,
371        validation_options,
372        no_parallel,
373        start,
374        query_type,
375        variables,
376    };
377    let outcome = execute_query_mode(streams, &execution_params)?;
378    let render_params = QueryRenderParams {
379        cli,
380        query_string: &effective_query,
381        verbose,
382        start,
383        relation_context,
384        index_info,
385    };
386    render_query_outcome(streams, outcome, render_params)
387}
388
389fn execute_query_mode(
390    streams: &mut OutputStreams,
391    params: &QueryExecutionParams<'_>,
392) -> Result<QueryExecutionOutcome> {
393    let cli = params.cli;
394    let query_string = params.query_string;
395    let search_path = params.search_path;
396    let validation_options = params.validation_options;
397    let no_parallel = params.no_parallel;
398    let start = params.start;
399    let query_type = params.query_type;
400    let variables = params.variables;
401
402    if should_use_hybrid_search(cli) {
403        let params = HybridQueryParams {
404            cli,
405            query_string,
406            search_path,
407            validation_options,
408            no_parallel,
409            start,
410            query_type,
411            variables,
412        };
413        execute_hybrid_query(streams, &params)
414    } else {
415        execute_semantic_query(
416            cli,
417            query_string,
418            search_path,
419            validation_options,
420            no_parallel,
421            variables,
422        )
423    }
424}
425
426fn render_query_outcome(
427    streams: &mut OutputStreams,
428    outcome: QueryExecutionOutcome,
429    params: QueryRenderParams<'_>,
430) -> Result<()> {
431    let QueryRenderParams {
432        cli,
433        query_string,
434        verbose,
435        start,
436        relation_context,
437        index_info,
438    } = params;
439    if let QueryExecutionOutcome::Continue(mut execution) = outcome {
440        let elapsed = start.elapsed();
441        let execution = &mut *execution;
442        let diagnostics = QueryDiagnostics::Standard { index_info };
443        render_semantic_results(
444            cli,
445            streams,
446            query_string,
447            &mut execution.symbols,
448            &execution.stats,
449            elapsed,
450            verbose,
451            execution.executor.as_ref(),
452            &diagnostics,
453            relation_context,
454        )?;
455    }
456
457    Ok(())
458}
459
460fn execute_hybrid_query(
461    streams: &mut OutputStreams,
462    params: &HybridQueryParams<'_>,
463) -> Result<QueryExecutionOutcome> {
464    let cli = params.cli;
465    let query_string = params.query_string;
466    let search_path = params.search_path;
467    let validation_options = params.validation_options;
468    let no_parallel = params.no_parallel;
469    let start = params.start;
470    let query_type = params.query_type;
471    let variables = params.variables;
472
473    // Resolve variables in the query string for hybrid search.
474    // FallbackSearchEngine doesn't support variable threading, so we resolve
475    // at the AST level and serialize back to a query string before passing it.
476    let effective_query = if let Some(vars) = variables {
477        let ast = QueryParser::parse_query(query_string)
478            .map_err(|e| anyhow::anyhow!("Failed to parse query for variable resolution: {e}"))?;
479        let resolved = sqry_core::query::types::resolve_variables(&ast.root, vars)
480            .map_err(|e| anyhow::anyhow!("{e}"))?;
481        let resolved_ast = sqry_core::query::types::Query {
482            root: resolved,
483            span: ast.span,
484        };
485        std::borrow::Cow::Owned(sqry_core::query::parsed_query::serialize_query(
486            &resolved_ast,
487        ))
488    } else {
489        std::borrow::Cow::Borrowed(query_string)
490    };
491
492    // Use hybrid search engine with plugin-enabled executor
493    // This allows metadata queries like async:true and visibility:public to work
494    let config = build_hybrid_config(cli);
495    let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
496        .with_validation_options(validation_options);
497    if no_parallel {
498        executor = executor.without_parallel();
499    }
500    let mut engine = FallbackSearchEngine::with_config_and_executor(config.clone(), executor)?;
501
502    emit_search_mode_diagnostic(cli, streams, query_type, &config)?;
503
504    let results = run_hybrid_search(cli, &mut engine, &effective_query, search_path)?;
505    let elapsed = start.elapsed();
506
507    match results {
508        SearchResults::Semantic { results, .. } => {
509            let symbols = query_results_to_display_symbols(&results);
510            Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
511                stats: build_query_stats(true, symbols.len()),
512                symbols,
513                executor: None,
514            })))
515        }
516        SearchResults::Text { matches, .. } => {
517            render_text_results(cli, streams, &matches, elapsed)?;
518            Ok(QueryExecutionOutcome::Terminal)
519        }
520    }
521}
522
523fn execute_semantic_query(
524    cli: &Cli,
525    query_string: &str,
526    search_path: &Path,
527    validation_options: ValidationOptions,
528    no_parallel: bool,
529    variables: Option<&std::collections::HashMap<String, String>>,
530) -> Result<QueryExecutionOutcome> {
531    let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
532        .with_validation_options(validation_options);
533    if no_parallel {
534        executor = executor.without_parallel();
535    }
536    let query_results =
537        executor.execute_on_graph_with_variables(query_string, search_path, variables)?;
538    let symbols = query_results_to_display_symbols(&query_results);
539    let stats = SimpleQueryStats { used_index: true };
540    Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
541        stats,
542        symbols,
543        executor: Some(executor),
544    })))
545}
546
547fn emit_search_mode_diagnostic(
548    cli: &Cli,
549    streams: &mut OutputStreams,
550    query_type: QueryType,
551    config: &FallbackConfig,
552) -> Result<()> {
553    if !config.show_search_mode || cli.json {
554        return Ok(());
555    }
556
557    let message = match query_type {
558        QueryType::Semantic => "[Semantic search mode]",
559        QueryType::Text => "[Text search mode]",
560        QueryType::Hybrid => "[Hybrid mode: trying semantic first...]",
561    };
562    streams.write_diagnostic(message)?;
563    Ok(())
564}
565
566fn run_hybrid_search(
567    cli: &Cli,
568    engine: &mut FallbackSearchEngine,
569    query_string: &str,
570    search_path: &Path,
571) -> Result<SearchResults> {
572    if cli.text {
573        // Force text-only search
574        engine.search_text_only(query_string, search_path)
575    } else if cli.semantic {
576        // Force semantic-only search
577        engine.search_semantic_only(query_string, search_path)
578    } else {
579        // Automatic hybrid search with fallback
580        engine.search(query_string, search_path)
581    }
582}
583
584fn build_query_stats(used_index: bool, _symbol_count: usize) -> SimpleQueryStats {
585    SimpleQueryStats { used_index }
586}
587
588fn render_text_results(
589    cli: &Cli,
590    streams: &mut OutputStreams,
591    matches: &[TextMatch],
592    elapsed: Duration,
593) -> Result<()> {
594    if cli.json {
595        // JSON mode: serialize text matches directly
596        let json_output = serde_json::json!({
597            "text_matches": matches,
598            "match_count": matches.len(),
599            "execution_time_ms": elapsed.as_millis(),
600        });
601        streams.write_result(&serde_json::to_string_pretty(&json_output)?)?;
602    } else if cli.count {
603        // Count mode: just show the count
604        streams.write_result(&matches.len().to_string())?;
605    } else {
606        // Normal mode: print matches in grep format
607        for m in matches {
608            streams.write_result(&format!(
609                "{}:{}:{}",
610                m.path.display(),
611                m.line,
612                m.line_text.trim()
613            ))?;
614        }
615
616        // Show performance info to stderr (not in JSON or count mode)
617        streams.write_diagnostic(&format!(
618            "\nQuery executed ({}ms) - {} text matches found",
619            elapsed.as_millis(),
620            matches.len()
621        ))?;
622    }
623
624    Ok(())
625}
626
627// RKG: CODE:SQRY-CLI implements REQ:SQRY-RUBY-QUALIFIED-CALLERS (MEDIUM-2 fix)
628fn run_query_with_session(
629    cli: &Cli,
630    streams: &mut OutputStreams,
631    query_string: &str,
632    search_path: &str,
633    verbose: bool,
634    _no_parallel: bool,
635    relation_ctx: &RelationDisplayContext,
636) -> Result<()> {
637    if cli.text {
638        bail!("--session is only available for semantic queries (remove --text)");
639    }
640
641    let search_path_path = Path::new(search_path);
642
643    // Index ancestor discovery for session mode
644    let (workspace, relative_scope, is_file_query, is_ancestor) =
645        resolve_session_index(search_path_path)?;
646
647    // Build index diagnostic info (for ancestor index or file queries)
648    let index_info = if is_ancestor || relative_scope.is_some() {
649        // Build filtered_to with proper format (file vs directory)
650        let filtered_to = relative_scope.as_ref().map(|p| {
651            if is_file_query {
652                p.to_string_lossy().into_owned()
653            } else {
654                format!("{}/**", p.display())
655            }
656        });
657        IndexDiagnosticInfo {
658            index_root: Some(workspace.clone()),
659            filtered_to,
660            used_ancestor_index: is_ancestor,
661        }
662    } else {
663        IndexDiagnosticInfo::default()
664    };
665
666    // Augment query with scope filter if using ancestor index
667    let effective_query: std::borrow::Cow<'_, str> = if let Some(ref scope) = relative_scope {
668        std::borrow::Cow::Owned(augment_query_with_scope(query_string, scope, is_file_query))
669    } else {
670        std::borrow::Cow::Borrowed(query_string)
671    };
672
673    // Check session cache first before expensive validation
674    // (fixes CODEX MEDIUM-2: avoid validation on warm queries)
675    let mut guard = QUERY_SESSION
676        .lock()
677        .expect("global session cache mutex poisoned");
678
679    if guard.is_none() {
680        // Cold start: create session (graph will be loaded on first query)
681        let config = sqry_core::session::SessionConfig::default();
682        *guard = Some(
683            SessionManager::with_config(config).context("failed to initialise session manager")?,
684        );
685    }
686
687    let session = guard.as_ref().expect("session manager must be initialised");
688    let before = session.stats();
689    let start = Instant::now();
690    let query_results = session
691        .query(&workspace, &effective_query)
692        .with_context(|| format!("failed to execute query \"{}\"", &effective_query))?;
693    let elapsed = start.elapsed();
694    let after = session.stats();
695    let cache_hit = after.cache_hits > before.cache_hits;
696
697    let mut symbols = query_results_to_display_symbols(&query_results);
698
699    let stats = SimpleQueryStats { used_index: true };
700
701    let diagnostics = QueryDiagnostics::Session {
702        cache_hit,
703        stats: after,
704        index_info,
705    };
706    render_semantic_results(
707        cli,
708        streams,
709        &effective_query,
710        &mut symbols,
711        &stats,
712        elapsed,
713        verbose,
714        None,
715        &diagnostics,
716        relation_ctx,
717    )
718}
719
720/// Resolve index location for session mode, walking up directory tree if needed.
721///
722/// Returns `(index_root, relative_scope, is_file_query, is_ancestor)` for query augmentation.
723/// For session mode, file paths are not supported (must be directory).
724fn resolve_session_index(path: &Path) -> Result<(PathBuf, Option<PathBuf>, bool, bool)> {
725    if !path.exists() {
726        bail!(
727            "session mode requires a directory ({} does not exist)",
728            path.display()
729        );
730    }
731
732    // Session mode requires a directory, not a file
733    if path.is_file() {
734        bail!(
735            "session mode requires a directory path ({} is a file). \
736             For file-specific queries, omit --session.",
737            path.display()
738        );
739    }
740
741    // Use index discovery to find nearest .sqry-index
742    if let Some(loc) = find_nearest_index(path) {
743        let relative_scope = if loc.requires_scope_filter {
744            loc.relative_scope()
745        } else {
746            None
747        };
748        Ok((
749            loc.index_root,
750            relative_scope,
751            loc.is_file_query,
752            loc.is_ancestor,
753        ))
754    } else {
755        bail!(
756            "no index found at {} or any parent directory. \
757             Run `sqry index <root>` first.",
758            path.display()
759        );
760    }
761}
762
763fn ensure_repo_predicate_not_present(query_string: &str) -> Result<()> {
764    if let Ok(query) = QueryParser::parse_query(query_string) {
765        if expr_has_repo_predicate(&query.root) {
766            bail!(
767                "repo: filters are only supported via `sqry workspace query` (multi-repo command)"
768            );
769        }
770        return Ok(());
771    }
772
773    if query_string.contains("repo:") {
774        bail!("repo: filters are only supported via `sqry workspace query` (multi-repo command)");
775    }
776
777    Ok(())
778}
779
780fn expr_has_repo_predicate(expr: &Expr) -> bool {
781    match expr {
782        Expr::And(operands) | Expr::Or(operands) => operands.iter().any(expr_has_repo_predicate),
783        Expr::Not(operand) => expr_has_repo_predicate(operand),
784        Expr::Condition(condition) => condition.field.as_str() == "repo",
785        Expr::Join(join) => {
786            expr_has_repo_predicate(&join.left) || expr_has_repo_predicate(&join.right)
787        }
788    }
789}
790
791/// Info about which index was used and any scope filtering applied.
792#[derive(Default)]
793struct IndexDiagnosticInfo {
794    /// Path to the index root directory (where .sqry-index lives)
795    index_root: Option<PathBuf>,
796    /// Scope filter applied (e.g., "src/**" or "main.rs")
797    filtered_to: Option<String>,
798    /// True if index was found in an ancestor directory
799    used_ancestor_index: bool,
800}
801
802enum QueryDiagnostics {
803    Standard {
804        index_info: IndexDiagnosticInfo,
805    },
806    Session {
807        cache_hit: bool,
808        stats: SessionStats,
809        index_info: IndexDiagnosticInfo,
810    },
811}
812
813struct QueryLimitInfo {
814    total_matches: usize,
815    limit: usize,
816    truncated: bool,
817}
818
819#[allow(clippy::too_many_arguments)]
820fn render_semantic_results(
821    cli: &Cli,
822    streams: &mut OutputStreams,
823    query_string: &str,
824    symbols: &mut Vec<DisplaySymbol>,
825    stats: &SimpleQueryStats,
826    elapsed: Duration,
827    verbose: bool,
828    executor_opt: Option<&QueryExecutor>,
829    diagnostics: &QueryDiagnostics,
830    relation_ctx: &RelationDisplayContext,
831) -> Result<()> {
832    // Optional sorting (opt-in)
833    apply_sorting(cli, symbols);
834
835    // Apply limit if specified (default: 1000 for query command)
836    let limit_info = apply_symbol_limit(symbols, cli.limit.unwrap_or(DEFAULT_QUERY_LIMIT));
837
838    // Extract index info from diagnostics for JSON output
839    let index_info = match diagnostics {
840        QueryDiagnostics::Standard { index_info }
841        | QueryDiagnostics::Session { index_info, .. } => index_info,
842    };
843
844    // Build metadata for structured JSON output
845    let metadata =
846        build_formatter_metadata(query_string, limit_info.total_matches, elapsed, index_info);
847
848    let identity_overrides = build_identity_overrides(cli, symbols, relation_ctx);
849
850    let display_symbols =
851        build_display_symbols_with_identities(symbols, identity_overrides.as_ref());
852
853    // Create formatter based on CLI flags
854    format_semantic_output(cli, streams, &display_symbols, &metadata)?;
855
856    maybe_emit_truncation_notice(cli, &limit_info);
857
858    if cli.json || cli.count {
859        return Ok(());
860    }
861
862    write_query_summary(streams, stats, elapsed, symbols.len(), diagnostics)?;
863
864    if verbose {
865        emit_verbose_cache_stats(streams, stats, executor_opt, diagnostics)?;
866    }
867
868    maybe_emit_debug_cache(cli, streams, executor_opt, stats)?;
869
870    Ok(())
871}
872
873fn apply_sorting(cli: &Cli, symbols: &mut [DisplaySymbol]) {
874    if let Some(sort_field) = cli.sort {
875        crate::commands::sort::sort_symbols(symbols, sort_field);
876    }
877}
878
879fn apply_symbol_limit(symbols: &mut Vec<DisplaySymbol>, limit: usize) -> QueryLimitInfo {
880    let total_matches = symbols.len();
881    let truncated = total_matches > limit;
882    if truncated {
883        symbols.truncate(limit);
884    }
885    QueryLimitInfo {
886        total_matches,
887        limit,
888        truncated,
889    }
890}
891
892fn build_formatter_metadata(
893    query_string: &str,
894    total_matches: usize,
895    elapsed: Duration,
896    index_info: &IndexDiagnosticInfo,
897) -> crate::output::FormatterMetadata {
898    crate::output::FormatterMetadata {
899        pattern: Some(query_string.to_string()),
900        total_matches,
901        execution_time: elapsed,
902        filters: sqry_core::json_response::Filters {
903            kind: None,
904            lang: None,
905            ignore_case: false,
906            exact: false,
907            fuzzy: None,
908        },
909        index_age_seconds: None,
910        // Include scope info when any filtering is applied (ancestor or file query)
911        used_ancestor_index: if index_info.used_ancestor_index || index_info.filtered_to.is_some() {
912            Some(index_info.used_ancestor_index)
913        } else {
914            None
915        },
916        filtered_to: index_info.filtered_to.clone(),
917    }
918}
919
920fn build_identity_overrides(
921    cli: &Cli,
922    symbols: &[DisplaySymbol],
923    relation_ctx: &RelationDisplayContext,
924) -> Option<DisplayIdentities> {
925    if cli.qualified_names || cli.json {
926        Some(compute_display_identities(symbols, relation_ctx))
927    } else {
928        None
929    }
930}
931
932fn format_semantic_output(
933    cli: &Cli,
934    streams: &mut OutputStreams,
935    display_symbols: &[DisplaySymbol],
936    metadata: &crate::output::FormatterMetadata,
937) -> Result<()> {
938    let formatter = create_formatter(cli);
939    formatter.format(display_symbols, Some(metadata), streams)?;
940    Ok(())
941}
942
943fn maybe_emit_truncation_notice(cli: &Cli, limit_info: &QueryLimitInfo) {
944    if !cli.json && limit_info.truncated {
945        eprintln!(
946            "\nShowing {} of {} matches (use --limit to adjust)",
947            limit_info.limit, limit_info.total_matches
948        );
949    }
950}
951
952fn build_display_symbols_with_identities(
953    symbols: &[DisplaySymbol],
954    identity_overrides: Option<&DisplayIdentities>,
955) -> Vec<DisplaySymbol> {
956    match identity_overrides {
957        Some(identities) => symbols
958            .iter()
959            .enumerate()
960            .map(|(idx, symbol)| {
961                let invoker_identity = identities
962                    .invoker_identities
963                    .get(idx)
964                    .and_then(Clone::clone);
965                let target_identity = identities.target_identities.get(idx).and_then(Clone::clone);
966
967                // Use the appropriate constructor based on which identity is present
968                if invoker_identity.is_some() {
969                    symbol.clone().with_caller_identity(invoker_identity)
970                } else if target_identity.is_some() {
971                    symbol.clone().with_callee_identity(target_identity)
972                } else {
973                    symbol.clone()
974                }
975            })
976            .collect(),
977        None => symbols.to_vec(),
978    }
979}
980
981fn write_query_summary(
982    streams: &mut OutputStreams,
983    stats: &SimpleQueryStats,
984    elapsed: Duration,
985    symbol_count: usize,
986    diagnostics: &QueryDiagnostics,
987) -> Result<()> {
988    use std::fmt::Write as _;
989
990    streams.write_diagnostic("")?;
991
992    // Extract index_info from diagnostics
993    let index_info = match diagnostics {
994        QueryDiagnostics::Standard { index_info }
995        | QueryDiagnostics::Session { index_info, .. } => index_info,
996    };
997
998    // Build index status message with ancestor info if applicable
999    let index_status = if stats.used_index {
1000        if index_info.used_ancestor_index {
1001            if let Some(ref root) = index_info.index_root {
1002                format!("✓ Using index from {}", root.display())
1003            } else {
1004                "✓ Used index".to_string()
1005            }
1006        } else {
1007            "✓ Used index".to_string()
1008        }
1009    } else {
1010        "ℹ No index found".to_string()
1011    };
1012
1013    let mut msg = format!(
1014        "{} - Query executed ({}ms) - {} symbols found",
1015        index_status,
1016        elapsed.as_millis(),
1017        symbol_count
1018    );
1019
1020    // Add scope filter info if applicable (ancestor index or file query)
1021    if let Some(ref filtered_to) = index_info.filtered_to {
1022        let _ = write!(msg, " (filtered to {filtered_to})");
1023    }
1024
1025    if let QueryDiagnostics::Session { cache_hit, .. } = diagnostics {
1026        let cache_state = if *cache_hit {
1027            "session cache hit"
1028        } else {
1029            "session cache miss"
1030        };
1031        let _ = write!(msg, " [{cache_state}]");
1032    }
1033
1034    streams.write_diagnostic(&msg)?;
1035
1036    Ok(())
1037}
1038
1039fn emit_verbose_cache_stats(
1040    streams: &mut OutputStreams,
1041    _stats: &SimpleQueryStats,
1042    executor_opt: Option<&QueryExecutor>,
1043    diagnostics: &QueryDiagnostics,
1044) -> Result<()> {
1045    match (executor_opt, diagnostics) {
1046        (Some(executor), _) => emit_executor_cache_stats(streams, executor),
1047        (None, QueryDiagnostics::Session { stats, .. }) => emit_session_cache_stats(streams, stats),
1048        _ => emit_hybrid_cache_notice(streams),
1049    }
1050}
1051
1052fn emit_executor_cache_stats(streams: &mut OutputStreams, executor: &QueryExecutor) -> Result<()> {
1053    let (parse_stats, result_stats) = executor.cache_stats();
1054
1055    streams.write_diagnostic("")?;
1056    streams.write_diagnostic("Cache Statistics:")?;
1057
1058    let parse_msg = format!(
1059        "  Parse cache:  {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1060        parse_stats.hit_rate() * 100.0,
1061        parse_stats.hits,
1062        parse_stats.misses,
1063        parse_stats.evictions,
1064    );
1065    streams.write_diagnostic(&parse_msg)?;
1066
1067    let result_msg = format!(
1068        "  Result cache: {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1069        result_stats.hit_rate() * 100.0,
1070        result_stats.hits,
1071        result_stats.misses,
1072        result_stats.evictions,
1073    );
1074    streams.write_diagnostic(&result_msg)?;
1075
1076    Ok(())
1077}
1078
1079fn emit_session_cache_stats(streams: &mut OutputStreams, stats: &SessionStats) -> Result<()> {
1080    let total_cache_events = stats.cache_hits + stats.cache_misses;
1081    let hit_rate = if total_cache_events > 0 {
1082        (u64_to_f64_lossy(stats.cache_hits) / u64_to_f64_lossy(total_cache_events)) * 100.0
1083    } else {
1084        0.0
1085    };
1086
1087    streams.write_diagnostic("")?;
1088    streams.write_diagnostic("Session statistics:")?;
1089    let _ = streams.write_diagnostic(&format!("  Cached indexes : {}", stats.cached_graphs));
1090    let _ = streams.write_diagnostic(&format!("  Total queries  : {}", stats.total_queries));
1091    let _ = streams.write_diagnostic(&format!(
1092        "  Cache hits     : {} ({hit_rate:.1}% hit rate)",
1093        stats.cache_hits
1094    ));
1095    let _ = streams.write_diagnostic(&format!("  Cache misses   : {}", stats.cache_misses));
1096    let _ = streams.write_diagnostic(&format!(
1097        "  Estimated memory: ~{} MB",
1098        stats.total_memory_mb
1099    ));
1100
1101    Ok(())
1102}
1103
1104fn emit_hybrid_cache_notice(streams: &mut OutputStreams) -> Result<()> {
1105    streams.write_diagnostic("")?;
1106    streams.write_diagnostic("Cache statistics not available in hybrid search mode")?;
1107    Ok(())
1108}
1109
1110struct DisplayIdentities {
1111    invoker_identities: Vec<Option<CallIdentityMetadata>>,
1112    target_identities: Vec<Option<CallIdentityMetadata>>,
1113}
1114
1115fn compute_display_identities(
1116    symbols: &[DisplaySymbol],
1117    relation_ctx: &RelationDisplayContext,
1118) -> DisplayIdentities {
1119    // Build identity metadata from symbol qualified names for relation queries.
1120    // For callers: queries, each result is a caller and gets caller_identity.
1121    // For callees: queries, each result is a callee and gets callee_identity.
1122    let has_incoming_targets = !relation_ctx.caller_targets.is_empty();
1123    let has_outgoing_targets = !relation_ctx.callee_targets.is_empty();
1124
1125    let identities: Vec<Option<CallIdentityMetadata>> = symbols
1126        .iter()
1127        .map(build_identity_from_display_symbol)
1128        .collect();
1129
1130    if has_incoming_targets {
1131        DisplayIdentities {
1132            invoker_identities: identities,
1133            target_identities: vec![None; symbols.len()],
1134        }
1135    } else if has_outgoing_targets {
1136        DisplayIdentities {
1137            invoker_identities: vec![None; symbols.len()],
1138            target_identities: identities,
1139        }
1140    } else {
1141        DisplayIdentities {
1142            invoker_identities: vec![None; symbols.len()],
1143            target_identities: vec![None; symbols.len()],
1144        }
1145    }
1146}
1147
1148fn build_identity_from_display_symbol(symbol: &DisplaySymbol) -> Option<CallIdentityMetadata> {
1149    let language = symbol.metadata.get("__raw_language").map(String::as_str);
1150    let is_static = symbol
1151        .metadata
1152        .get("static")
1153        .is_some_and(|value| value == "true");
1154
1155    build_identity_from_qualified_name(&symbol.qualified_name, &symbol.kind, language, is_static)
1156}
1157fn build_identity_from_qualified_name(
1158    qualified: &str,
1159    kind: &str,
1160    language: Option<&str>,
1161    is_static: bool,
1162) -> Option<CallIdentityMetadata> {
1163    call_identity_from_qualified_name(qualified, kind, language, is_static)
1164}
1165
1166/// Format execution steps for display
1167fn format_execution_steps(steps: &[sqry_core::query::ExecutionStep]) -> String {
1168    steps
1169        .iter()
1170        .map(|step| {
1171            format!(
1172                "  {}. {} ({}ms)",
1173                step.step_num, step.operation, step.time_ms
1174            )
1175        })
1176        .collect::<Vec<_>>()
1177        .join("\n")
1178}
1179
1180/// Format cache status for display
1181fn format_cache_status(status: &sqry_core::query::CacheStatus) -> String {
1182    match (status.parse_cache_hit, status.result_cache_hit) {
1183        (true, true) => "HIT (100% cached)".to_string(),
1184        (true, false) => "PARTIAL HIT (query cached, results computed)".to_string(),
1185        (false, true) => "PARTIAL HIT (query parsed, results cached)".to_string(),
1186        (false, false) => "MISS (first run)".to_string(),
1187    }
1188}
1189
1190fn env_debug_cache_enabled() -> bool {
1191    matches!(
1192        env::var("SQRY_CACHE_DEBUG"),
1193        Ok(value) if value == "1" || value.eq_ignore_ascii_case("true")
1194    )
1195}
1196
1197#[derive(Default)]
1198struct RelationDisplayContext {
1199    caller_targets: Vec<String>,
1200    callee_targets: Vec<String>,
1201}
1202
1203impl RelationDisplayContext {
1204    fn from_query(query_str: &str) -> Self {
1205        match QueryParser::parse_query(query_str) {
1206            Ok(ast) => {
1207                let mut ctx = Self::default();
1208                collect_relation_targets(&ast.root, &mut ctx);
1209                ctx
1210            }
1211            Err(_) => Self::default(),
1212        }
1213    }
1214}
1215
1216fn collect_relation_targets(expr: &Expr, ctx: &mut RelationDisplayContext) {
1217    match expr {
1218        Expr::And(operands) | Expr::Or(operands) => {
1219            for operand in operands {
1220                collect_relation_targets(operand, ctx);
1221            }
1222        }
1223        Expr::Not(inner) => collect_relation_targets(inner, ctx),
1224        Expr::Join(join) => {
1225            collect_relation_targets(&join.left, ctx);
1226            collect_relation_targets(&join.right, ctx);
1227        }
1228        Expr::Condition(condition) => match condition.field.as_str() {
1229            "callers" => {
1230                if let Value::String(value) = &condition.value
1231                    && !value.is_empty()
1232                {
1233                    ctx.caller_targets.push(value.clone());
1234                }
1235            }
1236            "callees" => {
1237                if let Value::String(value) = &condition.value
1238                    && !value.is_empty()
1239                {
1240                    ctx.callee_targets.push(value.clone());
1241                }
1242            }
1243            _ => {}
1244        },
1245    }
1246}
1247
1248fn should_debug_cache(cli: &Cli) -> bool {
1249    cli.debug_cache || env_debug_cache_enabled()
1250}
1251
1252// RKG: CODE:SQRY-CLI implements REQ:SQRY-P2-6-CACHE-EVICTION-POLICY
1253fn maybe_emit_debug_cache(
1254    cli: &Cli,
1255    streams: &mut OutputStreams,
1256    executor_opt: Option<&QueryExecutor>,
1257    _stats: &SimpleQueryStats,
1258) -> Result<()> {
1259    if !should_debug_cache(cli) {
1260        return Ok(());
1261    }
1262
1263    let Some(executor) = executor_opt else {
1264        streams.write_diagnostic("CacheStats unavailable in this mode")?;
1265        return Ok(());
1266    };
1267
1268    let (parse_stats, result_stats) = executor.cache_stats();
1269
1270    let debug_line = format!(
1271        "CacheStats{{parse_hits={}, parse_misses={}, result_hits={}, result_misses={}}}",
1272        parse_stats.hits, parse_stats.misses, result_stats.hits, result_stats.misses,
1273    );
1274    streams.write_diagnostic(&debug_line)?;
1275    Ok(())
1276}
1277
1278/// Build hybrid search configuration from CLI flags
1279fn build_hybrid_config(cli: &Cli) -> FallbackConfig {
1280    let mut config = FallbackConfig::from_env();
1281
1282    // Override with CLI flags
1283    if cli.no_fallback {
1284        config.fallback_enabled = false;
1285    }
1286
1287    config.text_context_lines = cli.context;
1288    config.max_text_results = cli.max_text_results;
1289
1290    // Disable search mode output in JSON mode
1291    if cli.json {
1292        config.show_search_mode = false;
1293    }
1294
1295    config
1296}
1297
1298/// Determine if hybrid search should be used based on CLI flags
1299fn should_use_hybrid_search(cli: &Cli) -> bool {
1300    // Cache debugging requires direct access to QueryExecutor stats.
1301    if should_debug_cache(cli) {
1302        return false;
1303    }
1304
1305    // Always use hybrid search (it handles --text, --semantic, and hybrid modes)
1306    // The only reason NOT to use it would be if hybrid search is explicitly disabled
1307    // via environment variable or if we need old behavior for compatibility
1308    true
1309}
1310
1311/// Create a `QueryExecutor` with all built-in plugins registered
1312pub(crate) fn create_executor_with_plugins() -> QueryExecutor {
1313    let plugin_manager = crate::plugin_defaults::create_plugin_manager();
1314    QueryExecutor::with_plugin_manager(plugin_manager)
1315}
1316
1317pub(crate) fn create_executor_with_plugins_for_cli(
1318    cli: &Cli,
1319    search_path: &Path,
1320) -> Result<QueryExecutor> {
1321    let effective_root = find_nearest_index(search_path)
1322        .map_or_else(|| search_path.to_path_buf(), |location| location.index_root);
1323    let resolved_plugins = plugin_defaults::resolve_plugin_selection(
1324        cli,
1325        &effective_root,
1326        PluginSelectionMode::ReadOnly,
1327    )?;
1328    Ok(QueryExecutor::with_plugin_manager(
1329        resolved_plugins.plugin_manager,
1330    ))
1331}
1332
1333fn u64_to_f64_lossy(value: u64) -> f64 {
1334    let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
1335    f64::from(narrowed)
1336}
1337
1338// ============================================================================
1339// Variable, Join, and Pipeline support
1340// ============================================================================
1341
1342/// Parse `--var KEY=VALUE` arguments into a `HashMap`.
1343fn parse_variable_args(args: &[String]) -> Result<std::collections::HashMap<String, String>> {
1344    let mut map = std::collections::HashMap::new();
1345    for arg in args {
1346        let (key, value) = arg
1347            .split_once('=')
1348            .ok_or_else(|| anyhow::anyhow!("Invalid --var format: '{arg}'. Expected KEY=VALUE"))?;
1349        if key.is_empty() {
1350            bail!("Variable name cannot be empty in --var '{arg}'");
1351        }
1352        map.insert(key.to_string(), value.to_string());
1353    }
1354    Ok(map)
1355}
1356
1357/// Check if a query string contains a join expression at the root level.
1358///
1359/// Returns `false` on parse errors (the normal flow will handle the error).
1360fn is_join_query(query_str: &str) -> bool {
1361    match QueryParser::parse_query(query_str) {
1362        Ok(ast) => matches!(ast.root, Expr::Join(_)),
1363        Err(_) => false,
1364    }
1365}
1366
1367/// Detect a pipeline query (base query | aggregation stages).
1368///
1369/// If the query string contains a `|` character, pipeline parse errors are
1370/// treated as hard errors (the user intended a pipeline query). If no `|`
1371/// is present, returns `None` (not a pipeline query).
1372fn detect_pipeline_query(
1373    query_str: &str,
1374) -> Result<Option<sqry_core::query::types::PipelineQuery>> {
1375    match QueryParser::parse_pipeline_query(query_str) {
1376        Ok(result) => Ok(result),
1377        Err(e) => {
1378            // If the query contains a pipe, the user intended a pipeline query
1379            // and the parse error should be surfaced (not silently ignored).
1380            if query_str.contains('|') {
1381                Err(anyhow::anyhow!("Pipeline parse error: {e}"))
1382            } else {
1383                Ok(None)
1384            }
1385        }
1386    }
1387}
1388
1389/// Run a join query and render results.
1390fn run_join_query(
1391    cli: &Cli,
1392    streams: &mut OutputStreams,
1393    query_string: &str,
1394    search_path: &str,
1395    no_parallel: bool,
1396    variables: Option<&std::collections::HashMap<String, String>>,
1397) -> Result<()> {
1398    let validation_options = build_validation_options(cli);
1399    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
1400        .with_validation_options(validation_options);
1401    if no_parallel {
1402        executor = executor.without_parallel();
1403    }
1404
1405    let resolved_path = Path::new(search_path);
1406    let join_results = executor.execute_join(query_string, resolved_path, variables)?;
1407
1408    if join_results.truncated() {
1409        streams.write_diagnostic(&format!(
1410            "Join query: {} pairs matched via {} (results truncated — cap reached)",
1411            join_results.len(),
1412            join_results.edge_kind()
1413        ))?;
1414    } else {
1415        streams.write_diagnostic(&format!(
1416            "Join query: {} pairs matched via {}",
1417            join_results.len(),
1418            join_results.edge_kind()
1419        ))?;
1420    }
1421
1422    for pair in join_results.iter() {
1423        let left_name = pair.left.name().unwrap_or_default();
1424        let left_path = pair
1425            .left
1426            .relative_path()
1427            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1428        let right_name = pair.right.name().unwrap_or_default();
1429        let right_path = pair
1430            .right
1431            .relative_path()
1432            .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1433
1434        if cli.json {
1435            // JSON mode: each pair as a JSON object
1436            let json = serde_json::json!({
1437                "left": {
1438                    "name": left_name.as_ref(),
1439                    "kind": pair.left.kind().as_str(),
1440                    "path": left_path,
1441                    "line": pair.left.start_line(),
1442                },
1443                "edge": pair.edge_kind.to_string(),
1444                "right": {
1445                    "name": right_name.as_ref(),
1446                    "kind": pair.right.kind().as_str(),
1447                    "path": right_path,
1448                    "line": pair.right.start_line(),
1449                },
1450            });
1451            streams.write_result(&json.to_string())?;
1452        } else {
1453            streams.write_result(&format!(
1454                "{} ({}:{}) {} {} ({}:{})",
1455                left_name,
1456                left_path,
1457                pair.left.start_line(),
1458                pair.edge_kind,
1459                right_name,
1460                right_path,
1461                pair.right.start_line(),
1462            ))?;
1463        }
1464    }
1465
1466    Ok(())
1467}
1468
1469/// Run a pipeline query (base query + aggregation stages) and render results.
1470fn run_pipeline_query(
1471    cli: &Cli,
1472    streams: &mut OutputStreams,
1473    _query_string: &str,
1474    search_path: &str,
1475    pipeline: &sqry_core::query::types::PipelineQuery,
1476    no_parallel: bool,
1477    variables: Option<&std::collections::HashMap<String, String>>,
1478) -> Result<()> {
1479    let validation_options = build_validation_options(cli);
1480    let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
1481        .with_validation_options(validation_options);
1482    if no_parallel {
1483        executor = executor.without_parallel();
1484    }
1485
1486    let resolved_path = Path::new(search_path);
1487
1488    // Execute the base query portion (before the pipe)
1489    // Serialize the base query from the parsed AST for reliable reconstruction
1490    let base_query = sqry_core::query::parsed_query::serialize_query(&pipeline.query);
1491
1492    let results =
1493        executor.execute_on_graph_with_variables(&base_query, resolved_path, variables)?;
1494
1495    // Execute each pipeline stage
1496    for stage in &pipeline.stages {
1497        let aggregation = sqry_core::query::execute_pipeline_stage(&results, stage);
1498
1499        if cli.json {
1500            render_aggregation_json(streams, &aggregation)?;
1501        } else {
1502            streams.write_result(&format!("{aggregation}"))?;
1503        }
1504    }
1505
1506    Ok(())
1507}
1508
1509/// Render aggregation results as JSON.
1510fn render_aggregation_json(
1511    streams: &mut OutputStreams,
1512    aggregation: &sqry_core::query::pipeline::AggregationResult,
1513) -> Result<()> {
1514    use sqry_core::query::pipeline::AggregationResult;
1515    let json = match aggregation {
1516        AggregationResult::Count(r) => serde_json::json!({
1517            "type": "count",
1518            "total": r.total,
1519        }),
1520        AggregationResult::GroupBy(r) => serde_json::json!({
1521            "type": "group_by",
1522            "field": r.field,
1523            "groups": r.groups.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
1524        }),
1525        AggregationResult::Top(r) => serde_json::json!({
1526            "type": "top",
1527            "field": r.field,
1528            "n": r.n,
1529            "entries": r.entries.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
1530        }),
1531        AggregationResult::Stats(r) => serde_json::json!({
1532            "type": "stats",
1533            "total": r.total,
1534            "by_kind": r.by_kind.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
1535            "by_lang": r.by_lang.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
1536            "by_visibility": r.by_visibility.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
1537        }),
1538    };
1539    streams.write_result(&json.to_string())?;
1540    Ok(())
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545    use super::*;
1546    use sqry_core::relations::CallIdentityKind;
1547
1548    // ==========================================================================
1549    // u64_to_f64_lossy tests
1550    // ==========================================================================
1551
1552    #[test]
1553    fn test_u64_to_f64_lossy_zero() {
1554        assert!((u64_to_f64_lossy(0) - 0.0).abs() < f64::EPSILON);
1555    }
1556
1557    #[test]
1558    fn test_u64_to_f64_lossy_small_values() {
1559        assert!((u64_to_f64_lossy(1) - 1.0).abs() < f64::EPSILON);
1560        assert!((u64_to_f64_lossy(100) - 100.0).abs() < f64::EPSILON);
1561        assert!((u64_to_f64_lossy(1000) - 1000.0).abs() < f64::EPSILON);
1562    }
1563
1564    #[test]
1565    fn test_u64_to_f64_lossy_u32_max() {
1566        let u32_max = u64::from(u32::MAX);
1567        assert!((u64_to_f64_lossy(u32_max) - f64::from(u32::MAX)).abs() < f64::EPSILON);
1568    }
1569
1570    #[test]
1571    fn test_u64_to_f64_lossy_overflow_clamps_to_u32_max() {
1572        // Values larger than u32::MAX should clamp
1573        let large_value = u64::from(u32::MAX) + 1;
1574        assert!((u64_to_f64_lossy(large_value) - f64::from(u32::MAX)).abs() < f64::EPSILON);
1575    }
1576
1577    // ==========================================================================
1578    // format_cache_status tests
1579    // ==========================================================================
1580
1581    #[test]
1582    fn test_format_cache_status_full_hit() {
1583        let status = sqry_core::query::CacheStatus {
1584            parse_cache_hit: true,
1585            result_cache_hit: true,
1586        };
1587        assert_eq!(format_cache_status(&status), "HIT (100% cached)");
1588    }
1589
1590    #[test]
1591    fn test_format_cache_status_parse_hit_only() {
1592        let status = sqry_core::query::CacheStatus {
1593            parse_cache_hit: true,
1594            result_cache_hit: false,
1595        };
1596        assert_eq!(
1597            format_cache_status(&status),
1598            "PARTIAL HIT (query cached, results computed)"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_format_cache_status_result_hit_only() {
1604        let status = sqry_core::query::CacheStatus {
1605            parse_cache_hit: false,
1606            result_cache_hit: true,
1607        };
1608        assert_eq!(
1609            format_cache_status(&status),
1610            "PARTIAL HIT (query parsed, results cached)"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_format_cache_status_full_miss() {
1616        let status = sqry_core::query::CacheStatus {
1617            parse_cache_hit: false,
1618            result_cache_hit: false,
1619        };
1620        assert_eq!(format_cache_status(&status), "MISS (first run)");
1621    }
1622
1623    // ==========================================================================
1624    // format_execution_steps tests
1625    // ==========================================================================
1626
1627    #[test]
1628    fn test_format_execution_steps_empty() {
1629        let steps: Vec<sqry_core::query::ExecutionStep> = vec![];
1630        assert_eq!(format_execution_steps(&steps), "");
1631    }
1632
1633    #[test]
1634    fn test_format_execution_steps_single() {
1635        let steps = vec![sqry_core::query::ExecutionStep {
1636            step_num: 1,
1637            operation: "Parse query".to_string(),
1638            result_count: 0,
1639            time_ms: 5,
1640        }];
1641        assert_eq!(format_execution_steps(&steps), "  1. Parse query (5ms)");
1642    }
1643
1644    #[test]
1645    fn test_format_execution_steps_multiple() {
1646        let steps = vec![
1647            sqry_core::query::ExecutionStep {
1648                step_num: 1,
1649                operation: "Parse".to_string(),
1650                result_count: 100,
1651                time_ms: 2,
1652            },
1653            sqry_core::query::ExecutionStep {
1654                step_num: 2,
1655                operation: "Optimize".to_string(),
1656                result_count: 50,
1657                time_ms: 3,
1658            },
1659            sqry_core::query::ExecutionStep {
1660                step_num: 3,
1661                operation: "Execute".to_string(),
1662                result_count: 25,
1663                time_ms: 10,
1664            },
1665        ];
1666        let expected = "  1. Parse (2ms)\n  2. Optimize (3ms)\n  3. Execute (10ms)";
1667        assert_eq!(format_execution_steps(&steps), expected);
1668    }
1669
1670    // ==========================================================================
1671    // expr_has_repo_predicate tests
1672    // ==========================================================================
1673
1674    #[test]
1675    fn test_expr_has_repo_predicate_simple_repo() {
1676        let query = QueryParser::parse_query("repo:myrepo").unwrap();
1677        assert!(expr_has_repo_predicate(&query.root));
1678    }
1679
1680    #[test]
1681    fn test_expr_has_repo_predicate_no_repo() {
1682        let query = QueryParser::parse_query("kind:function").unwrap();
1683        assert!(!expr_has_repo_predicate(&query.root));
1684    }
1685
1686    #[test]
1687    fn test_expr_has_repo_predicate_nested_and() {
1688        let query = QueryParser::parse_query("kind:function AND repo:myrepo").unwrap();
1689        assert!(expr_has_repo_predicate(&query.root));
1690    }
1691
1692    #[test]
1693    fn test_expr_has_repo_predicate_nested_or() {
1694        let query = QueryParser::parse_query("kind:function OR repo:myrepo").unwrap();
1695        assert!(expr_has_repo_predicate(&query.root));
1696    }
1697
1698    #[test]
1699    fn test_expr_has_repo_predicate_nested_not() {
1700        let query = QueryParser::parse_query("NOT repo:myrepo").unwrap();
1701        assert!(expr_has_repo_predicate(&query.root));
1702    }
1703
1704    #[test]
1705    fn test_expr_has_repo_predicate_complex_no_repo() {
1706        let query = QueryParser::parse_query("kind:function AND name:foo OR lang:rust").unwrap();
1707        assert!(!expr_has_repo_predicate(&query.root));
1708    }
1709
1710    // ==========================================================================
1711    // RelationDisplayContext tests
1712    // ==========================================================================
1713
1714    #[test]
1715    fn test_relation_context_no_relations() {
1716        let ctx = RelationDisplayContext::from_query("kind:function");
1717        assert!(ctx.caller_targets.is_empty());
1718        assert!(ctx.callee_targets.is_empty());
1719    }
1720
1721    #[test]
1722    fn test_relation_context_with_callers() {
1723        let ctx = RelationDisplayContext::from_query("callers:foo");
1724        assert_eq!(ctx.caller_targets, vec!["foo"]);
1725        assert!(ctx.callee_targets.is_empty());
1726    }
1727
1728    #[test]
1729    fn test_relation_context_with_callees() {
1730        let ctx = RelationDisplayContext::from_query("callees:bar");
1731        assert!(ctx.caller_targets.is_empty());
1732        assert_eq!(ctx.callee_targets, vec!["bar"]);
1733    }
1734
1735    #[test]
1736    fn test_relation_context_with_both() {
1737        let ctx = RelationDisplayContext::from_query("callers:foo AND callees:bar");
1738        assert_eq!(ctx.caller_targets, vec!["foo"]);
1739        assert_eq!(ctx.callee_targets, vec!["bar"]);
1740    }
1741
1742    #[test]
1743    fn test_relation_context_invalid_query() {
1744        // Invalid queries should return default context
1745        let ctx = RelationDisplayContext::from_query("invalid query syntax ???");
1746        assert!(ctx.caller_targets.is_empty());
1747        assert!(ctx.callee_targets.is_empty());
1748    }
1749
1750    #[test]
1751    fn test_build_identity_from_qualified_name_preserves_ruby_instance_display() {
1752        let identity = build_identity_from_qualified_name(
1753            "Admin::Users::Controller::show",
1754            "method",
1755            Some("ruby"),
1756            false,
1757        )
1758        .expect("ruby instance identity");
1759
1760        assert_eq!(identity.qualified, "Admin::Users::Controller#show");
1761        assert_eq!(identity.method_kind, CallIdentityKind::Instance);
1762    }
1763
1764    #[test]
1765    fn test_build_identity_from_qualified_name_preserves_ruby_singleton_display() {
1766        let identity = build_identity_from_qualified_name(
1767            "Admin::Users::Controller::show",
1768            "method",
1769            Some("ruby"),
1770            true,
1771        )
1772        .expect("ruby singleton identity");
1773
1774        assert_eq!(identity.qualified, "Admin::Users::Controller.show");
1775        assert_eq!(identity.method_kind, CallIdentityKind::Singleton);
1776    }
1777
1778    // ==========================================================================
1779    // ensure_repo_predicate_not_present tests
1780    // ==========================================================================
1781
1782    #[test]
1783    fn test_ensure_repo_not_present_ok() {
1784        let result = ensure_repo_predicate_not_present("kind:function");
1785        assert!(result.is_ok());
1786    }
1787
1788    #[test]
1789    fn test_ensure_repo_not_present_fails_with_repo() {
1790        let result = ensure_repo_predicate_not_present("repo:myrepo");
1791        assert!(result.is_err());
1792        assert!(
1793            result
1794                .unwrap_err()
1795                .to_string()
1796                .contains("repo: filters are only supported")
1797        );
1798    }
1799
1800    #[test]
1801    fn test_ensure_repo_not_present_fails_with_nested_repo() {
1802        let result = ensure_repo_predicate_not_present("kind:function AND repo:myrepo");
1803        assert!(result.is_err());
1804    }
1805
1806    #[test]
1807    fn test_ensure_repo_not_present_fallback_text_check() {
1808        // Even if query doesn't parse, text-based check should work
1809        let result = ensure_repo_predicate_not_present("invalid??? repo:something");
1810        assert!(result.is_err());
1811    }
1812
1813    // ==========================================================================
1814    // parse_variable_args tests
1815    // ==========================================================================
1816
1817    #[test]
1818    fn test_parse_variable_args_empty() {
1819        let result = parse_variable_args(&[]).unwrap();
1820        assert!(result.is_empty());
1821    }
1822
1823    #[test]
1824    fn test_parse_variable_args_single_key_value() {
1825        let args = vec!["FOO=bar".to_string()];
1826        let result = parse_variable_args(&args).unwrap();
1827        assert_eq!(result.len(), 1);
1828        assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
1829    }
1830
1831    #[test]
1832    fn test_parse_variable_args_multiple() {
1833        let args = vec!["A=1".to_string(), "B=hello world".to_string()];
1834        let result = parse_variable_args(&args).unwrap();
1835        assert_eq!(result.len(), 2);
1836        assert_eq!(result.get("A"), Some(&"1".to_string()));
1837        assert_eq!(result.get("B"), Some(&"hello world".to_string()));
1838    }
1839
1840    #[test]
1841    fn test_parse_variable_args_value_with_equals() {
1842        // Only the first '=' is the separator; rest is the value
1843        let args = vec!["KEY=val=ue".to_string()];
1844        let result = parse_variable_args(&args).unwrap();
1845        assert_eq!(result.get("KEY"), Some(&"val=ue".to_string()));
1846    }
1847
1848    #[test]
1849    fn test_parse_variable_args_no_equals_errors() {
1850        let args = vec!["NOEQUALS".to_string()];
1851        let err = parse_variable_args(&args).unwrap_err();
1852        assert!(
1853            err.to_string().contains("Invalid --var format"),
1854            "Unexpected error: {err}"
1855        );
1856    }
1857
1858    #[test]
1859    fn test_parse_variable_args_empty_key_errors() {
1860        let args = vec!["=value".to_string()];
1861        let err = parse_variable_args(&args).unwrap_err();
1862        assert!(
1863            err.to_string().contains("Variable name cannot be empty"),
1864            "Unexpected error: {err}"
1865        );
1866    }
1867
1868    #[test]
1869    fn test_parse_variable_args_empty_value_allowed() {
1870        let args = vec!["KEY=".to_string()];
1871        let result = parse_variable_args(&args).unwrap();
1872        assert_eq!(result.get("KEY"), Some(&String::new()));
1873    }
1874
1875    // ==========================================================================
1876    // is_join_query tests
1877    // ==========================================================================
1878
1879    #[test]
1880    fn test_is_join_query_non_join() {
1881        assert!(!is_join_query("kind:function"));
1882        assert!(!is_join_query("name:foo AND kind:method"));
1883    }
1884
1885    #[test]
1886    fn test_is_join_query_invalid_query_returns_false() {
1887        // parse errors → false, not panic
1888        assert!(!is_join_query("invalid ??? syntax {{{"));
1889    }
1890
1891    #[test]
1892    fn test_is_join_query_positive() {
1893        // A valid join expression uses the CALLS operator between two sub-queries.
1894        // The parser recognises `(lhs) CALLS (rhs)` as a Join expression.
1895        assert!(
1896            is_join_query("(kind:function) CALLS (kind:function)"),
1897            "CALLS join expression must be detected as a join query"
1898        );
1899    }
1900
1901    // ==========================================================================
1902    // detect_pipeline_query tests
1903    // ==========================================================================
1904
1905    #[test]
1906    fn test_detect_pipeline_query_no_pipe_returns_none() {
1907        let result = detect_pipeline_query("kind:function").unwrap();
1908        assert!(result.is_none());
1909    }
1910
1911    #[test]
1912    fn test_detect_pipeline_query_invalid_without_pipe_returns_none() {
1913        // No pipe → even parse errors silently return None
1914        let result = detect_pipeline_query("invalid query !!!").unwrap();
1915        assert!(result.is_none());
1916    }
1917
1918    #[test]
1919    fn test_detect_pipeline_query_invalid_with_pipe_errors() {
1920        // A well-formed pipeline query (base `|` valid stage) must return Ok.
1921        // The presence of `|` only turns parse *errors* into hard errors; a
1922        // successful parse must always return Ok(Some(_)).
1923        let result = detect_pipeline_query("kind:function | count");
1924        assert!(
1925            result.is_ok(),
1926            "A valid pipeline query must return Ok, got: {result:?}"
1927        );
1928        assert!(
1929            result.unwrap().is_some(),
1930            "A valid pipeline query must return Ok(Some(_))"
1931        );
1932    }
1933
1934    // ==========================================================================
1935    // apply_symbol_limit tests
1936    // ==========================================================================
1937
1938    #[test]
1939    fn test_apply_symbol_limit_no_truncation() {
1940        let mut symbols: Vec<DisplaySymbol> = (0..5)
1941            .map(|i| DisplaySymbol {
1942                name: format!("sym{i}"),
1943                qualified_name: format!("sym{i}"),
1944                kind: "function".to_string(),
1945                file_path: std::path::PathBuf::from("a.rs"),
1946                start_line: i,
1947                start_column: 0,
1948                end_line: i,
1949                end_column: 0,
1950                metadata: std::collections::HashMap::new(),
1951                caller_identity: None,
1952                callee_identity: None,
1953            })
1954            .collect();
1955
1956        let info = apply_symbol_limit(&mut symbols, 10);
1957        assert_eq!(symbols.len(), 5);
1958        assert!(!info.truncated);
1959        assert_eq!(info.total_matches, 5);
1960        assert_eq!(info.limit, 10);
1961    }
1962
1963    #[test]
1964    fn test_apply_symbol_limit_truncates() {
1965        let mut symbols: Vec<DisplaySymbol> = (0..20)
1966            .map(|i| DisplaySymbol {
1967                name: format!("sym{i}"),
1968                qualified_name: format!("sym{i}"),
1969                kind: "function".to_string(),
1970                file_path: std::path::PathBuf::from("a.rs"),
1971                start_line: i,
1972                start_column: 0,
1973                end_line: i,
1974                end_column: 0,
1975                metadata: std::collections::HashMap::new(),
1976                caller_identity: None,
1977                callee_identity: None,
1978            })
1979            .collect();
1980
1981        let info = apply_symbol_limit(&mut symbols, 5);
1982        assert_eq!(symbols.len(), 5);
1983        assert!(info.truncated);
1984        assert_eq!(info.total_matches, 20);
1985        assert_eq!(info.limit, 5);
1986    }
1987
1988    #[test]
1989    fn test_apply_symbol_limit_exact_boundary() {
1990        let mut symbols: Vec<DisplaySymbol> = (0..5)
1991            .map(|i| DisplaySymbol {
1992                name: format!("sym{i}"),
1993                qualified_name: format!("sym{i}"),
1994                kind: "function".to_string(),
1995                file_path: std::path::PathBuf::from("a.rs"),
1996                start_line: i,
1997                start_column: 0,
1998                end_line: i,
1999                end_column: 0,
2000                metadata: std::collections::HashMap::new(),
2001                caller_identity: None,
2002                callee_identity: None,
2003            })
2004            .collect();
2005
2006        let info = apply_symbol_limit(&mut symbols, 5);
2007        assert_eq!(symbols.len(), 5);
2008        assert!(!info.truncated, "Exact boundary should not truncate");
2009    }
2010
2011    // ==========================================================================
2012    // u64_to_f64_lossy additional edge cases
2013    // ==========================================================================
2014
2015    #[test]
2016    fn test_u64_to_f64_lossy_large_values_clamp_to_u32_max() {
2017        let very_large = u64::MAX;
2018        let result = u64_to_f64_lossy(very_large);
2019        // Should clamp to u32::MAX
2020        assert!((result - f64::from(u32::MAX)).abs() < f64::EPSILON);
2021    }
2022
2023    // ==========================================================================
2024    // env_debug_cache_enabled tests
2025    // ==========================================================================
2026
2027    #[serial_test::serial]
2028    #[test]
2029    fn test_env_debug_cache_disabled_by_default() {
2030        // In a clean test environment, SQRY_CACHE_DEBUG should not be set
2031        // (if it is set externally, we skip this test)
2032        unsafe {
2033            std::env::remove_var("SQRY_CACHE_DEBUG");
2034        }
2035        assert!(!env_debug_cache_enabled());
2036    }
2037
2038    #[serial_test::serial]
2039    #[test]
2040    fn test_env_debug_cache_enabled_with_1() {
2041        unsafe {
2042            std::env::set_var("SQRY_CACHE_DEBUG", "1");
2043        }
2044        let result = env_debug_cache_enabled();
2045        unsafe {
2046            std::env::remove_var("SQRY_CACHE_DEBUG");
2047        }
2048        assert!(result);
2049    }
2050
2051    #[serial_test::serial]
2052    #[test]
2053    fn test_env_debug_cache_enabled_with_true() {
2054        unsafe {
2055            std::env::set_var("SQRY_CACHE_DEBUG", "true");
2056        }
2057        let result = env_debug_cache_enabled();
2058        unsafe {
2059            std::env::remove_var("SQRY_CACHE_DEBUG");
2060        }
2061        assert!(result);
2062    }
2063
2064    #[serial_test::serial]
2065    #[test]
2066    fn test_env_debug_cache_enabled_with_true_uppercase() {
2067        unsafe {
2068            std::env::set_var("SQRY_CACHE_DEBUG", "TRUE");
2069        }
2070        let result = env_debug_cache_enabled();
2071        unsafe {
2072            std::env::remove_var("SQRY_CACHE_DEBUG");
2073        }
2074        assert!(result);
2075    }
2076
2077    #[serial_test::serial]
2078    #[test]
2079    fn test_env_debug_cache_disabled_with_zero() {
2080        unsafe {
2081            std::env::set_var("SQRY_CACHE_DEBUG", "0");
2082        }
2083        let result = env_debug_cache_enabled();
2084        unsafe {
2085            std::env::remove_var("SQRY_CACHE_DEBUG");
2086        }
2087        assert!(!result);
2088    }
2089
2090    // ==========================================================================
2091    // build_query_stats tests
2092    // ==========================================================================
2093
2094    #[test]
2095    fn test_build_query_stats_with_index() {
2096        let stats = build_query_stats(true, 10);
2097        assert!(stats.used_index);
2098    }
2099
2100    #[test]
2101    fn test_build_query_stats_without_index() {
2102        let stats = build_query_stats(false, 10);
2103        assert!(!stats.used_index);
2104    }
2105}