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