1use crate::args::Cli;
5use crate::index_discovery::{augment_query_with_scope, find_nearest_index};
6use crate::output::{
7 DisplaySymbol, OutputStreams, call_identity_from_qualified_name, create_formatter,
8};
9use crate::plugin_defaults::{self, PluginSelectionMode};
10use anyhow::{Context, Result, bail};
11use sqry_core::graph::{
12 AcquisitionOperation, AutoBuildHook, FilesystemGraphProvider, GraphAcquirer, GraphAcquisition,
13 GraphAcquisitionError, GraphAcquisitionRequest, MissingGraphPolicy, PathPolicy,
14 PluginSelectionPolicy, PluginSelectionStatus, StalePolicy,
15};
16use sqry_core::query::QueryExecutor;
17use sqry_core::query::parser_new::Parser as QueryParser;
18use sqry_core::query::results::QueryResults;
19use sqry_core::query::security::QuerySecurityConfig;
20use sqry_core::query::types::{Expr, Value};
21use sqry_core::query::validator::ValidationOptions;
22use sqry_core::relations::CallIdentityMetadata;
23use sqry_core::search::Match as TextMatch;
24use sqry_core::search::classifier::{QueryClassifier, QueryType};
25use sqry_core::search::fallback::{FallbackConfig, FallbackSearchEngine, SearchResults};
26use sqry_core::session::{SessionManager, SessionStats};
27use std::env;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant};
31
32static QUERY_SESSION: std::sync::LazyLock<Mutex<Option<SessionManager>>> =
33 std::sync::LazyLock::new(|| Mutex::new(None));
34
35const DEFAULT_QUERY_LIMIT: usize = 1000;
36
37#[derive(Debug, Clone, Default)]
39struct SimpleQueryStats {
40 used_index: bool,
42}
43
44fn query_results_to_display_symbols(results: &QueryResults) -> Vec<DisplaySymbol> {
49 results
50 .iter()
51 .map(|m| DisplaySymbol::from_query_match(&m))
52 .collect()
53}
54
55struct QueryExecution {
56 stats: SimpleQueryStats,
57 symbols: Vec<DisplaySymbol>,
58 executor: Option<QueryExecutor>,
59}
60
61enum QueryExecutionOutcome {
62 Terminal,
63 Continue(Box<QueryExecution>),
64}
65
66struct NonSessionQueryParams<'a> {
67 cli: &'a Cli,
68 query_string: &'a str,
69 search_path: &'a str,
70 validation_options: ValidationOptions,
71 verbose: bool,
72 no_parallel: bool,
73 relation_context: &'a RelationDisplayContext,
74 variables: Option<&'a std::collections::HashMap<String, String>>,
75}
76
77struct QueryExecutionParams<'a> {
78 cli: &'a Cli,
79 query_string: &'a str,
80 search_path: &'a Path,
81 validation_options: ValidationOptions,
82 no_parallel: bool,
83 start: Instant,
84 query_type: QueryType,
85 variables: Option<&'a std::collections::HashMap<String, String>>,
86 acquisition: &'a GraphAcquisition,
90}
91
92struct QueryRenderParams<'a> {
93 cli: &'a Cli,
94 query_string: &'a str,
95 verbose: bool,
96 start: Instant,
97 relation_context: &'a RelationDisplayContext,
98 index_info: IndexDiagnosticInfo,
99}
100
101struct HybridQueryParams<'a> {
102 cli: &'a Cli,
103 query_string: &'a str,
104 search_path: &'a Path,
105 validation_options: ValidationOptions,
106 no_parallel: bool,
107 start: Instant,
108 query_type: QueryType,
109 variables: Option<&'a std::collections::HashMap<String, String>>,
110 acquisition: &'a GraphAcquisition,
117}
118
119#[allow(clippy::too_many_arguments)]
143#[allow(clippy::fn_params_excessive_bools)] pub fn run_query(
145 cli: &Cli,
146 query_string: &str,
147 search_path: &str,
148 explain: bool,
149 verbose: bool,
150 session_mode: bool,
151 no_parallel: bool,
152 timeout_secs: Option<u64>,
153 result_limit: Option<usize>,
154 variables: &[String],
155) -> Result<()> {
156 let mut streams = OutputStreams::with_pager(cli.pager_config());
158
159 ensure_repo_predicate_not_present(query_string)?;
160
161 let validation_options = build_validation_options(cli);
162
163 let security_config = build_security_config(timeout_secs, result_limit);
165 maybe_emit_security_diagnostics(&mut streams, &security_config, verbose)?;
166
167 let _ = &security_config; let parsed_variables = parse_variable_args(variables)?;
173 let variables_opt = if parsed_variables.is_empty() {
174 None
175 } else {
176 Some(&parsed_variables)
177 };
178
179 if !explain {
192 validate_query_path_strict(Path::new(search_path))?;
193 }
194
195 if let Some(pipeline) = detect_pipeline_query(query_string)? {
197 run_pipeline_query(
198 cli,
199 &mut streams,
200 query_string,
201 search_path,
202 &pipeline,
203 no_parallel,
204 variables_opt,
205 )?;
206 return streams.finish_checked();
207 }
208
209 if is_join_query(query_string) {
211 run_join_query(
212 cli,
213 &mut streams,
214 query_string,
215 search_path,
216 no_parallel,
217 variables_opt,
218 )?;
219 return streams.finish_checked();
220 }
221
222 if explain {
224 run_query_explain(query_string, validation_options, no_parallel, &mut streams)?;
225 return streams.finish_checked();
226 }
227
228 let relation_context = RelationDisplayContext::from_query(query_string);
229
230 if session_mode {
235 let result = run_query_with_session(
236 cli,
237 &mut streams,
238 query_string,
239 search_path,
240 verbose,
241 no_parallel,
242 &relation_context,
243 );
244 result?;
247 return streams.finish_checked();
248 }
249
250 let params = NonSessionQueryParams {
251 cli,
252 query_string,
253 search_path,
254 validation_options,
255 verbose,
256 no_parallel,
257 relation_context: &relation_context,
258 variables: variables_opt,
259 };
260 run_query_non_session(&mut streams, ¶ms)?;
261
262 streams.finish_checked()
264}
265
266fn build_validation_options(cli: &Cli) -> ValidationOptions {
267 ValidationOptions {
268 fuzzy_fields: cli.fuzzy_fields,
269 fuzzy_field_distance: cli.fuzzy_field_distance,
270 }
271}
272
273fn build_security_config(
274 timeout_secs: Option<u64>,
275 result_limit: Option<usize>,
276) -> QuerySecurityConfig {
277 let mut config = QuerySecurityConfig::default();
278 if let Some(secs) = timeout_secs {
279 config = config.with_timeout(Duration::from_secs(secs));
280 }
281 if let Some(limit) = result_limit {
282 config = config.with_result_cap(limit);
283 }
284 config
285}
286
287fn maybe_emit_security_diagnostics(
288 streams: &mut OutputStreams,
289 security_config: &QuerySecurityConfig,
290 verbose: bool,
291) -> Result<()> {
292 if verbose {
293 streams.write_diagnostic(&format!(
294 "[Security] timeout={}s, limit={}, memory={}MB",
295 security_config.timeout().as_secs(),
296 security_config.result_cap(),
297 security_config.memory_limit() / (1024 * 1024),
298 ))?;
299 }
300 Ok(())
301}
302
303fn run_query_explain(
304 query_string: &str,
305 validation_options: ValidationOptions,
306 no_parallel: bool,
307 streams: &mut OutputStreams,
308) -> Result<()> {
309 let mut executor = create_executor_with_plugins().with_validation_options(validation_options);
310 if no_parallel {
311 executor = executor.without_parallel();
312 }
313 let plan = executor.get_query_plan(query_string)?;
314 let explain_output = format!(
315 "Query Plan:\n Original: {}\n Optimized: {}\n\nExecution:\n{}\n\nPerformance:\n Execution time: {}ms\n Index-aware: {}\n Cache: {}",
316 plan.original_query,
317 plan.optimized_query,
318 format_execution_steps(&plan.steps),
319 plan.execution_time_ms,
320 if plan.used_index { "Yes" } else { "No" },
321 format_cache_status(&plan.cache_status),
322 );
323 streams.write_diagnostic(&explain_output)?;
324 Ok(())
325}
326
327struct EffectiveIndexResolution {
329 index_root: PathBuf,
330 query: String,
331 info: IndexDiagnosticInfo,
332}
333
334fn resolve_effective_index_root(
337 search_path: &Path,
338 query_string: &str,
339) -> EffectiveIndexResolution {
340 let index_location = find_nearest_index(search_path);
341
342 if let Some(ref loc) = index_location {
343 let root = loc.index_root.clone();
344 let (query, filtered_to) = if loc.requires_scope_filter {
345 if let Some(relative_scope) = loc.relative_scope() {
346 let scope_str = if loc.is_file_query {
347 relative_scope.to_string_lossy().into_owned()
348 } else {
349 format!("{}/**", relative_scope.display())
350 };
351 let augmented =
352 augment_query_with_scope(query_string, &relative_scope, loc.is_file_query);
353 (augmented, Some(scope_str))
354 } else {
355 (query_string.to_string(), None)
356 }
357 } else {
358 (query_string.to_string(), None)
359 };
360 let info = IndexDiagnosticInfo {
361 index_root: Some(root.clone()),
362 filtered_to,
363 used_ancestor_index: loc.is_ancestor,
364 };
365 EffectiveIndexResolution {
366 index_root: root,
367 query,
368 info,
369 }
370 } else {
371 EffectiveIndexResolution {
372 index_root: search_path.to_path_buf(),
373 query: query_string.to_string(),
374 info: IndexDiagnosticInfo::default(),
375 }
376 }
377}
378
379fn run_query_non_session(
380 streams: &mut OutputStreams,
381 params: &NonSessionQueryParams<'_>,
382) -> Result<()> {
383 let NonSessionQueryParams {
384 cli,
385 query_string,
386 search_path,
387 validation_options,
388 verbose,
389 no_parallel,
390 relation_context,
391 variables,
392 } = *params;
393 let search_path_path = Path::new(search_path);
394
395 if cli.text {
401 return run_query_text_only(streams, params);
402 }
403
404 if QueryClassifier::classify(query_string) == QueryType::Semantic {
424 probe_validate_query_syntax(cli, search_path_path, query_string, validation_options)?;
425 }
426
427 let acquisition = acquire_graph_for_cli(cli, search_path_path)?;
435
436 let resolution = resolve_effective_index_root(search_path_path, query_string);
440 let EffectiveIndexResolution {
441 index_root: effective_index_root,
442 query: effective_query,
443 info: index_info,
444 } = resolution;
445
446 let query_type = QueryClassifier::classify(&effective_query);
447
448 let start = Instant::now();
449 let execution_params = QueryExecutionParams {
450 cli,
451 query_string: &effective_query,
452 search_path: &effective_index_root,
453 validation_options,
454 no_parallel,
455 start,
456 query_type,
457 variables,
458 acquisition: &acquisition,
459 };
460 let outcome = execute_query_mode(streams, &execution_params)?;
461 let render_params = QueryRenderParams {
462 cli,
463 query_string: &effective_query,
464 verbose,
465 start,
466 relation_context,
467 index_info,
468 };
469 render_query_outcome(streams, outcome, render_params)
470}
471
472fn execute_query_mode(
473 streams: &mut OutputStreams,
474 params: &QueryExecutionParams<'_>,
475) -> Result<QueryExecutionOutcome> {
476 let cli = params.cli;
477 let query_string = params.query_string;
478 let search_path = params.search_path;
479 let validation_options = params.validation_options;
480 let no_parallel = params.no_parallel;
481 let start = params.start;
482 let query_type = params.query_type;
483 let variables = params.variables;
484 let acquisition = params.acquisition;
485
486 if should_use_hybrid_search(cli) {
487 let params = HybridQueryParams {
488 cli,
489 query_string,
490 search_path,
491 validation_options,
492 no_parallel,
493 start,
494 query_type,
495 variables,
496 acquisition,
497 };
498 execute_hybrid_query(streams, ¶ms)
499 } else {
500 execute_semantic_query(
501 cli,
502 query_string,
503 search_path,
504 validation_options,
505 no_parallel,
506 variables,
507 acquisition,
508 )
509 }
510}
511
512fn render_query_outcome(
513 streams: &mut OutputStreams,
514 outcome: QueryExecutionOutcome,
515 params: QueryRenderParams<'_>,
516) -> Result<()> {
517 let QueryRenderParams {
518 cli,
519 query_string,
520 verbose,
521 start,
522 relation_context,
523 index_info,
524 } = params;
525 if let QueryExecutionOutcome::Continue(mut execution) = outcome {
526 let elapsed = start.elapsed();
527 let execution = &mut *execution;
528 let diagnostics = QueryDiagnostics::Standard { index_info };
529 render_semantic_results(
530 cli,
531 streams,
532 query_string,
533 &mut execution.symbols,
534 &execution.stats,
535 elapsed,
536 verbose,
537 execution.executor.as_ref(),
538 &diagnostics,
539 relation_context,
540 )?;
541 }
542
543 Ok(())
544}
545
546fn run_query_text_only(
559 streams: &mut OutputStreams,
560 params: &NonSessionQueryParams<'_>,
561) -> Result<()> {
562 let NonSessionQueryParams {
563 cli,
564 query_string,
565 search_path,
566 ..
567 } = *params;
568 let search_path_path = Path::new(search_path);
569
570 let config = build_hybrid_config(cli);
586 let mut engine = FallbackSearchEngine::with_config(config)?;
587
588 let start = Instant::now();
589 let results = engine.search_text_only(query_string, search_path_path)?;
590 let elapsed = start.elapsed();
591
592 match results {
593 SearchResults::Text { matches, .. } => {
594 render_text_results(cli, streams, &matches, elapsed)?;
595 }
596 SearchResults::Semantic { results, .. } => {
597 let mut symbols = query_results_to_display_symbols(&results);
601 let stats = SimpleQueryStats { used_index: false };
602 let diagnostics = QueryDiagnostics::Standard {
603 index_info: IndexDiagnosticInfo::default(),
604 };
605 render_semantic_results(
606 cli,
607 streams,
608 query_string,
609 &mut symbols,
610 &stats,
611 elapsed,
612 params.verbose,
613 None,
614 &diagnostics,
615 params.relation_context,
616 )?;
617 }
618 }
619 Ok(())
620}
621
622fn execute_hybrid_query(
623 streams: &mut OutputStreams,
624 params: &HybridQueryParams<'_>,
625) -> Result<QueryExecutionOutcome> {
626 let cli = params.cli;
627 let query_string = params.query_string;
628 let search_path = params.search_path;
629 let validation_options = params.validation_options;
630 let no_parallel = params.no_parallel;
631 let start = params.start;
632 let query_type = params.query_type;
633 let variables = params.variables;
634 let acquisition = params.acquisition;
635
636 let effective_query = if let Some(vars) = variables {
640 let ast = QueryParser::parse_query(query_string)
641 .map_err(|e| anyhow::anyhow!("Failed to parse query for variable resolution: {e}"))?;
642 let resolved = sqry_core::query::types::resolve_variables(&ast.root, vars)
643 .map_err(|e| anyhow::anyhow!("{e}"))?;
644 let resolved_ast = sqry_core::query::types::Query {
645 root: resolved,
646 span: ast.span,
647 };
648 std::borrow::Cow::Owned(sqry_core::query::parsed_query::serialize_query(
649 &resolved_ast,
650 ))
651 } else {
652 std::borrow::Cow::Borrowed(query_string)
653 };
654
655 let config = build_hybrid_config(cli);
658 let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
659 .with_validation_options(validation_options);
660 if no_parallel {
661 executor = executor.without_parallel();
662 }
663 let mut engine = FallbackSearchEngine::with_config_and_executor(config.clone(), executor)?;
664
665 emit_search_mode_diagnostic(cli, streams, query_type, &config)?;
666
667 let results = run_hybrid_search(cli, &mut engine, &effective_query, search_path, acquisition)?;
668 let elapsed = start.elapsed();
669
670 match results {
671 SearchResults::Semantic { results, .. } => {
672 let symbols = query_results_to_display_symbols(&results);
673 Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
674 stats: build_query_stats(true, symbols.len()),
675 symbols,
676 executor: None,
677 })))
678 }
679 SearchResults::Text { matches, .. } => {
680 render_text_results(cli, streams, &matches, elapsed)?;
681 Ok(QueryExecutionOutcome::Terminal)
682 }
683 }
684}
685
686fn execute_semantic_query(
687 cli: &Cli,
688 query_string: &str,
689 search_path: &Path,
690 validation_options: ValidationOptions,
691 no_parallel: bool,
692 variables: Option<&std::collections::HashMap<String, String>>,
693 acquisition: &GraphAcquisition,
694) -> Result<QueryExecutionOutcome> {
695 let mut executor = create_executor_with_plugins_for_cli(cli, search_path)?
696 .with_validation_options(validation_options);
697 if no_parallel {
698 executor = executor.without_parallel();
699 }
700 let query_results = executor.execute_on_preloaded_graph(
707 Arc::clone(&acquisition.graph),
708 query_string,
709 &acquisition.workspace_root,
710 variables,
711 )?;
712 let symbols = query_results_to_display_symbols(&query_results);
713 let stats = SimpleQueryStats { used_index: true };
714 Ok(QueryExecutionOutcome::Continue(Box::new(QueryExecution {
715 stats,
716 symbols,
717 executor: Some(executor),
718 })))
719}
720
721fn emit_search_mode_diagnostic(
722 cli: &Cli,
723 streams: &mut OutputStreams,
724 query_type: QueryType,
725 config: &FallbackConfig,
726) -> Result<()> {
727 if !config.show_search_mode || cli.json {
728 return Ok(());
729 }
730
731 let message = match query_type {
732 QueryType::Semantic => "[Semantic search mode]",
733 QueryType::Text => "[Text search mode]",
734 QueryType::Hybrid => "[Hybrid mode: trying semantic first...]",
735 };
736 streams.write_diagnostic(message)?;
737 Ok(())
738}
739
740fn run_hybrid_search(
741 cli: &Cli,
742 engine: &mut FallbackSearchEngine,
743 query_string: &str,
744 search_path: &Path,
745 acquisition: &GraphAcquisition,
746) -> Result<SearchResults> {
747 if cli.text {
748 engine.search_text_only(query_string, search_path)
750 } else if cli.semantic {
751 engine.search_semantic_only_with_preloaded_graph(
757 query_string,
758 Arc::clone(&acquisition.graph),
759 search_path,
760 )
761 } else {
762 engine.search_with_preloaded_graph(
766 query_string,
767 Arc::clone(&acquisition.graph),
768 search_path,
769 )
770 }
771}
772
773fn build_query_stats(used_index: bool, _symbol_count: usize) -> SimpleQueryStats {
774 SimpleQueryStats { used_index }
775}
776
777fn render_text_results(
778 cli: &Cli,
779 streams: &mut OutputStreams,
780 matches: &[TextMatch],
781 elapsed: Duration,
782) -> Result<()> {
783 if cli.json {
784 let json_output = serde_json::json!({
786 "text_matches": matches,
787 "match_count": matches.len(),
788 "execution_time_ms": elapsed.as_millis(),
789 });
790 streams.write_result(&serde_json::to_string_pretty(&json_output)?)?;
791 } else if cli.count {
792 streams.write_result(&matches.len().to_string())?;
794 } else {
795 for m in matches {
797 streams.write_result(&format!(
798 "{}:{}:{}",
799 m.path.display(),
800 m.line,
801 m.line_text.trim()
802 ))?;
803 }
804
805 streams.write_diagnostic(&format!(
807 "\nQuery executed ({}ms) - {} text matches found",
808 elapsed.as_millis(),
809 matches.len()
810 ))?;
811 }
812
813 Ok(())
814}
815
816fn run_query_with_session(
818 cli: &Cli,
819 streams: &mut OutputStreams,
820 query_string: &str,
821 search_path: &str,
822 verbose: bool,
823 _no_parallel: bool,
824 relation_ctx: &RelationDisplayContext,
825) -> Result<()> {
826 if cli.text {
827 bail!("--session is only available for semantic queries (remove --text)");
828 }
829
830 let search_path_path = Path::new(search_path);
831
832 if QueryClassifier::classify(query_string) == QueryType::Semantic {
841 probe_validate_query_syntax(
842 cli,
843 search_path_path,
844 query_string,
845 build_validation_options(cli),
846 )?;
847 }
848
849 match acquire_graph_for_cli_typed(cli, search_path_path, MissingGraphPolicy::Error)? {
866 Ok(_acquisition) => {}
867 Err(GraphAcquisitionError::NoGraph { .. }) => {
868 }
871 Err(other) => return Err(map_acquisition_error(other)),
872 }
873
874 let (workspace, relative_scope, is_file_query, is_ancestor) =
876 resolve_session_index(search_path_path)?;
877
878 let index_info = if is_ancestor || relative_scope.is_some() {
880 let filtered_to = relative_scope.as_ref().map(|p| {
882 if is_file_query {
883 p.to_string_lossy().into_owned()
884 } else {
885 format!("{}/**", p.display())
886 }
887 });
888 IndexDiagnosticInfo {
889 index_root: Some(workspace.clone()),
890 filtered_to,
891 used_ancestor_index: is_ancestor,
892 }
893 } else {
894 IndexDiagnosticInfo::default()
895 };
896
897 let effective_query: std::borrow::Cow<'_, str> = if let Some(ref scope) = relative_scope {
899 std::borrow::Cow::Owned(augment_query_with_scope(query_string, scope, is_file_query))
900 } else {
901 std::borrow::Cow::Borrowed(query_string)
902 };
903
904 let mut guard = QUERY_SESSION
907 .lock()
908 .expect("global session cache mutex poisoned");
909
910 if guard.is_none() {
911 let config = sqry_core::session::SessionConfig::default();
913 *guard = Some(
914 SessionManager::with_config(config).context("failed to initialise session manager")?,
915 );
916 }
917
918 let session = guard.as_ref().expect("session manager must be initialised");
919 let before = session.stats();
920 let start = Instant::now();
921 let query_results = session
922 .query(&workspace, &effective_query)
923 .with_context(|| format!("failed to execute query \"{}\"", &effective_query))?;
924 let elapsed = start.elapsed();
925 let after = session.stats();
926 let cache_hit = after.cache_hits > before.cache_hits;
927
928 let mut symbols = query_results_to_display_symbols(&query_results);
929
930 let stats = SimpleQueryStats { used_index: true };
931
932 let diagnostics = QueryDiagnostics::Session {
933 cache_hit,
934 stats: after,
935 index_info,
936 };
937 render_semantic_results(
938 cli,
939 streams,
940 &effective_query,
941 &mut symbols,
942 &stats,
943 elapsed,
944 verbose,
945 None,
946 &diagnostics,
947 relation_ctx,
948 )
949}
950
951fn resolve_session_index(path: &Path) -> Result<(PathBuf, Option<PathBuf>, bool, bool)> {
956 if !path.exists() {
957 bail!(
958 "session mode requires a directory ({} does not exist)",
959 path.display()
960 );
961 }
962
963 if path.is_file() {
965 bail!(
966 "session mode requires a directory path ({} is a file). \
967 For file-specific queries, omit --session.",
968 path.display()
969 );
970 }
971
972 if let Some(loc) = find_nearest_index(path) {
974 let relative_scope = if loc.requires_scope_filter {
975 loc.relative_scope()
976 } else {
977 None
978 };
979 Ok((
980 loc.index_root,
981 relative_scope,
982 loc.is_file_query,
983 loc.is_ancestor,
984 ))
985 } else {
986 bail!(
987 "no index found at {} or any parent directory. \
988 Run `sqry index <root>` first.",
989 path.display()
990 );
991 }
992}
993
994fn ensure_repo_predicate_not_present(query_string: &str) -> Result<()> {
995 if let Ok(query) = QueryParser::parse_query(query_string) {
996 if expr_has_repo_predicate(&query.root) {
997 bail!(
998 "repo: filters are only supported via `sqry workspace query` (multi-repo command)"
999 );
1000 }
1001 return Ok(());
1002 }
1003
1004 if query_string.contains("repo:") {
1005 bail!("repo: filters are only supported via `sqry workspace query` (multi-repo command)");
1006 }
1007
1008 Ok(())
1009}
1010
1011fn expr_has_repo_predicate(expr: &Expr) -> bool {
1012 match expr {
1013 Expr::And(operands) | Expr::Or(operands) => operands.iter().any(expr_has_repo_predicate),
1014 Expr::Not(operand) => expr_has_repo_predicate(operand),
1015 Expr::Condition(condition) => condition.field.as_str() == "repo",
1016 Expr::Join(join) => {
1017 expr_has_repo_predicate(&join.left) || expr_has_repo_predicate(&join.right)
1018 }
1019 }
1020}
1021
1022#[derive(Default)]
1024struct IndexDiagnosticInfo {
1025 index_root: Option<PathBuf>,
1027 filtered_to: Option<String>,
1029 used_ancestor_index: bool,
1031}
1032
1033enum QueryDiagnostics {
1034 Standard {
1035 index_info: IndexDiagnosticInfo,
1036 },
1037 Session {
1038 cache_hit: bool,
1039 stats: SessionStats,
1040 index_info: IndexDiagnosticInfo,
1041 },
1042}
1043
1044struct QueryLimitInfo {
1045 total_matches: usize,
1046 limit: usize,
1047 truncated: bool,
1048}
1049
1050#[allow(clippy::too_many_arguments)]
1051fn render_semantic_results(
1052 cli: &Cli,
1053 streams: &mut OutputStreams,
1054 query_string: &str,
1055 symbols: &mut Vec<DisplaySymbol>,
1056 stats: &SimpleQueryStats,
1057 elapsed: Duration,
1058 verbose: bool,
1059 executor_opt: Option<&QueryExecutor>,
1060 diagnostics: &QueryDiagnostics,
1061 relation_ctx: &RelationDisplayContext,
1062) -> Result<()> {
1063 apply_sorting(cli, symbols);
1065
1066 let limit_info = apply_symbol_limit(symbols, cli.limit.unwrap_or(DEFAULT_QUERY_LIMIT));
1068
1069 let index_info = match diagnostics {
1071 QueryDiagnostics::Standard { index_info }
1072 | QueryDiagnostics::Session { index_info, .. } => index_info,
1073 };
1074
1075 let metadata =
1077 build_formatter_metadata(query_string, limit_info.total_matches, elapsed, index_info);
1078
1079 let identity_overrides = build_identity_overrides(cli, symbols, relation_ctx);
1080
1081 let display_symbols =
1082 build_display_symbols_with_identities(symbols, identity_overrides.as_ref());
1083
1084 format_semantic_output(cli, streams, &display_symbols, &metadata)?;
1086
1087 maybe_emit_truncation_notice(cli, &limit_info);
1088
1089 if cli.json || cli.count {
1090 return Ok(());
1091 }
1092
1093 write_query_summary(streams, stats, elapsed, symbols.len(), diagnostics)?;
1094
1095 if verbose {
1096 emit_verbose_cache_stats(streams, stats, executor_opt, diagnostics)?;
1097 }
1098
1099 maybe_emit_debug_cache(cli, streams, executor_opt, stats)?;
1100
1101 Ok(())
1102}
1103
1104fn apply_sorting(cli: &Cli, symbols: &mut [DisplaySymbol]) {
1105 if let Some(sort_field) = cli.sort {
1106 crate::commands::sort::sort_symbols(symbols, sort_field);
1107 }
1108}
1109
1110fn apply_symbol_limit(symbols: &mut Vec<DisplaySymbol>, limit: usize) -> QueryLimitInfo {
1111 let total_matches = symbols.len();
1112 let truncated = total_matches > limit;
1113 if truncated {
1114 symbols.truncate(limit);
1115 }
1116 QueryLimitInfo {
1117 total_matches,
1118 limit,
1119 truncated,
1120 }
1121}
1122
1123fn build_formatter_metadata(
1124 query_string: &str,
1125 total_matches: usize,
1126 elapsed: Duration,
1127 index_info: &IndexDiagnosticInfo,
1128) -> crate::output::FormatterMetadata {
1129 crate::output::FormatterMetadata {
1130 pattern: Some(query_string.to_string()),
1131 total_matches,
1132 execution_time: elapsed,
1133 filters: sqry_core::json_response::Filters {
1134 kind: None,
1135 lang: None,
1136 ignore_case: false,
1137 exact: false,
1138 fuzzy: None,
1139 },
1140 index_age_seconds: None,
1141 used_ancestor_index: if index_info.used_ancestor_index || index_info.filtered_to.is_some() {
1143 Some(index_info.used_ancestor_index)
1144 } else {
1145 None
1146 },
1147 filtered_to: index_info.filtered_to.clone(),
1148 }
1149}
1150
1151fn build_identity_overrides(
1152 cli: &Cli,
1153 symbols: &[DisplaySymbol],
1154 relation_ctx: &RelationDisplayContext,
1155) -> Option<DisplayIdentities> {
1156 if cli.qualified_names || cli.json {
1157 Some(compute_display_identities(symbols, relation_ctx))
1158 } else {
1159 None
1160 }
1161}
1162
1163fn format_semantic_output(
1164 cli: &Cli,
1165 streams: &mut OutputStreams,
1166 display_symbols: &[DisplaySymbol],
1167 metadata: &crate::output::FormatterMetadata,
1168) -> Result<()> {
1169 let formatter = create_formatter(cli);
1170 formatter.format(display_symbols, Some(metadata), streams)?;
1171 Ok(())
1172}
1173
1174fn maybe_emit_truncation_notice(cli: &Cli, limit_info: &QueryLimitInfo) {
1175 if !cli.json && limit_info.truncated {
1176 eprintln!(
1177 "\nShowing {} of {} matches (use --limit to adjust)",
1178 limit_info.limit, limit_info.total_matches
1179 );
1180 }
1181}
1182
1183fn build_display_symbols_with_identities(
1184 symbols: &[DisplaySymbol],
1185 identity_overrides: Option<&DisplayIdentities>,
1186) -> Vec<DisplaySymbol> {
1187 match identity_overrides {
1188 Some(identities) => symbols
1189 .iter()
1190 .enumerate()
1191 .map(|(idx, symbol)| {
1192 let invoker_identity = identities
1193 .invoker_identities
1194 .get(idx)
1195 .and_then(Clone::clone);
1196 let target_identity = identities.target_identities.get(idx).and_then(Clone::clone);
1197
1198 if invoker_identity.is_some() {
1200 symbol.clone().with_caller_identity(invoker_identity)
1201 } else if target_identity.is_some() {
1202 symbol.clone().with_callee_identity(target_identity)
1203 } else {
1204 symbol.clone()
1205 }
1206 })
1207 .collect(),
1208 None => symbols.to_vec(),
1209 }
1210}
1211
1212fn write_query_summary(
1213 streams: &mut OutputStreams,
1214 stats: &SimpleQueryStats,
1215 elapsed: Duration,
1216 symbol_count: usize,
1217 diagnostics: &QueryDiagnostics,
1218) -> Result<()> {
1219 use std::fmt::Write as _;
1220
1221 streams.write_diagnostic("")?;
1222
1223 let index_info = match diagnostics {
1225 QueryDiagnostics::Standard { index_info }
1226 | QueryDiagnostics::Session { index_info, .. } => index_info,
1227 };
1228
1229 let index_status = if stats.used_index {
1231 if index_info.used_ancestor_index {
1232 if let Some(ref root) = index_info.index_root {
1233 format!("✓ Using index from {}", root.display())
1234 } else {
1235 "✓ Used index".to_string()
1236 }
1237 } else {
1238 "✓ Used index".to_string()
1239 }
1240 } else {
1241 "ℹ No index found".to_string()
1242 };
1243
1244 let mut msg = format!(
1245 "{} - Query executed ({}ms) - {} symbols found",
1246 index_status,
1247 elapsed.as_millis(),
1248 symbol_count
1249 );
1250
1251 if let Some(ref filtered_to) = index_info.filtered_to {
1253 let _ = write!(msg, " (filtered to {filtered_to})");
1254 }
1255
1256 if let QueryDiagnostics::Session { cache_hit, .. } = diagnostics {
1257 let cache_state = if *cache_hit {
1258 "session cache hit"
1259 } else {
1260 "session cache miss"
1261 };
1262 let _ = write!(msg, " [{cache_state}]");
1263 }
1264
1265 streams.write_diagnostic(&msg)?;
1266
1267 Ok(())
1268}
1269
1270fn emit_verbose_cache_stats(
1271 streams: &mut OutputStreams,
1272 _stats: &SimpleQueryStats,
1273 executor_opt: Option<&QueryExecutor>,
1274 diagnostics: &QueryDiagnostics,
1275) -> Result<()> {
1276 match (executor_opt, diagnostics) {
1277 (Some(executor), _) => emit_executor_cache_stats(streams, executor),
1278 (None, QueryDiagnostics::Session { stats, .. }) => emit_session_cache_stats(streams, stats),
1279 _ => emit_hybrid_cache_notice(streams),
1280 }
1281}
1282
1283fn emit_executor_cache_stats(streams: &mut OutputStreams, executor: &QueryExecutor) -> Result<()> {
1284 let (parse_stats, result_stats) = executor.cache_stats();
1285
1286 streams.write_diagnostic("")?;
1287 streams.write_diagnostic("Cache Statistics:")?;
1288
1289 let parse_msg = format!(
1290 " Parse cache: {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1291 parse_stats.hit_rate() * 100.0,
1292 parse_stats.hits,
1293 parse_stats.misses,
1294 parse_stats.evictions,
1295 );
1296 streams.write_diagnostic(&parse_msg)?;
1297
1298 let result_msg = format!(
1299 " Result cache: {:.1}% hit rate ({} hits, {} misses, {} evictions)",
1300 result_stats.hit_rate() * 100.0,
1301 result_stats.hits,
1302 result_stats.misses,
1303 result_stats.evictions,
1304 );
1305 streams.write_diagnostic(&result_msg)?;
1306
1307 Ok(())
1308}
1309
1310fn emit_session_cache_stats(streams: &mut OutputStreams, stats: &SessionStats) -> Result<()> {
1311 let total_cache_events = stats.cache_hits + stats.cache_misses;
1312 let hit_rate = if total_cache_events > 0 {
1313 (u64_to_f64_lossy(stats.cache_hits) / u64_to_f64_lossy(total_cache_events)) * 100.0
1314 } else {
1315 0.0
1316 };
1317
1318 streams.write_diagnostic("")?;
1319 streams.write_diagnostic("Session statistics:")?;
1320 let _ = streams.write_diagnostic(&format!(" Cached indexes : {}", stats.cached_graphs));
1321 let _ = streams.write_diagnostic(&format!(" Total queries : {}", stats.total_queries));
1322 let _ = streams.write_diagnostic(&format!(
1323 " Cache hits : {} ({hit_rate:.1}% hit rate)",
1324 stats.cache_hits
1325 ));
1326 let _ = streams.write_diagnostic(&format!(" Cache misses : {}", stats.cache_misses));
1327 let _ = streams.write_diagnostic(&format!(
1328 " Estimated memory: ~{} MB",
1329 stats.total_memory_mb
1330 ));
1331
1332 Ok(())
1333}
1334
1335fn emit_hybrid_cache_notice(streams: &mut OutputStreams) -> Result<()> {
1336 streams.write_diagnostic("")?;
1337 streams.write_diagnostic("Cache statistics not available in hybrid search mode")?;
1338 Ok(())
1339}
1340
1341struct DisplayIdentities {
1342 invoker_identities: Vec<Option<CallIdentityMetadata>>,
1343 target_identities: Vec<Option<CallIdentityMetadata>>,
1344}
1345
1346fn compute_display_identities(
1347 symbols: &[DisplaySymbol],
1348 relation_ctx: &RelationDisplayContext,
1349) -> DisplayIdentities {
1350 let has_incoming_targets = !relation_ctx.caller_targets.is_empty();
1354 let has_outgoing_targets = !relation_ctx.callee_targets.is_empty();
1355
1356 let identities: Vec<Option<CallIdentityMetadata>> = symbols
1357 .iter()
1358 .map(build_identity_from_display_symbol)
1359 .collect();
1360
1361 if has_incoming_targets {
1362 DisplayIdentities {
1363 invoker_identities: identities,
1364 target_identities: vec![None; symbols.len()],
1365 }
1366 } else if has_outgoing_targets {
1367 DisplayIdentities {
1368 invoker_identities: vec![None; symbols.len()],
1369 target_identities: identities,
1370 }
1371 } else {
1372 DisplayIdentities {
1373 invoker_identities: vec![None; symbols.len()],
1374 target_identities: vec![None; symbols.len()],
1375 }
1376 }
1377}
1378
1379fn build_identity_from_display_symbol(symbol: &DisplaySymbol) -> Option<CallIdentityMetadata> {
1380 let language = symbol.metadata.get("__raw_language").map(String::as_str);
1381 let is_static = symbol
1382 .metadata
1383 .get("static")
1384 .is_some_and(|value| value == "true");
1385
1386 build_identity_from_qualified_name(&symbol.qualified_name, &symbol.kind, language, is_static)
1387}
1388fn build_identity_from_qualified_name(
1389 qualified: &str,
1390 kind: &str,
1391 language: Option<&str>,
1392 is_static: bool,
1393) -> Option<CallIdentityMetadata> {
1394 call_identity_from_qualified_name(qualified, kind, language, is_static)
1395}
1396
1397fn format_execution_steps(steps: &[sqry_core::query::ExecutionStep]) -> String {
1399 steps
1400 .iter()
1401 .map(|step| {
1402 format!(
1403 " {}. {} ({}ms)",
1404 step.step_num, step.operation, step.time_ms
1405 )
1406 })
1407 .collect::<Vec<_>>()
1408 .join("\n")
1409}
1410
1411fn format_cache_status(status: &sqry_core::query::CacheStatus) -> String {
1413 match (status.parse_cache_hit, status.result_cache_hit) {
1414 (true, true) => "HIT (100% cached)".to_string(),
1415 (true, false) => "PARTIAL HIT (query cached, results computed)".to_string(),
1416 (false, true) => "PARTIAL HIT (query parsed, results cached)".to_string(),
1417 (false, false) => "MISS (first run)".to_string(),
1418 }
1419}
1420
1421fn env_debug_cache_enabled() -> bool {
1422 matches!(
1423 env::var("SQRY_CACHE_DEBUG"),
1424 Ok(value) if value == "1" || value.eq_ignore_ascii_case("true")
1425 )
1426}
1427
1428#[derive(Default)]
1429struct RelationDisplayContext {
1430 caller_targets: Vec<String>,
1431 callee_targets: Vec<String>,
1432}
1433
1434impl RelationDisplayContext {
1435 fn from_query(query_str: &str) -> Self {
1436 match QueryParser::parse_query(query_str) {
1437 Ok(ast) => {
1438 let mut ctx = Self::default();
1439 collect_relation_targets(&ast.root, &mut ctx);
1440 ctx
1441 }
1442 Err(_) => Self::default(),
1443 }
1444 }
1445}
1446
1447fn collect_relation_targets(expr: &Expr, ctx: &mut RelationDisplayContext) {
1448 match expr {
1449 Expr::And(operands) | Expr::Or(operands) => {
1450 for operand in operands {
1451 collect_relation_targets(operand, ctx);
1452 }
1453 }
1454 Expr::Not(inner) => collect_relation_targets(inner, ctx),
1455 Expr::Join(join) => {
1456 collect_relation_targets(&join.left, ctx);
1457 collect_relation_targets(&join.right, ctx);
1458 }
1459 Expr::Condition(condition) => match condition.field.as_str() {
1460 "callers" => {
1461 if let Value::String(value) = &condition.value
1462 && !value.is_empty()
1463 {
1464 ctx.caller_targets.push(value.clone());
1465 }
1466 }
1467 "callees" => {
1468 if let Value::String(value) = &condition.value
1469 && !value.is_empty()
1470 {
1471 ctx.callee_targets.push(value.clone());
1472 }
1473 }
1474 _ => {}
1475 },
1476 }
1477}
1478
1479fn should_debug_cache(cli: &Cli) -> bool {
1480 cli.debug_cache || env_debug_cache_enabled()
1481}
1482
1483fn maybe_emit_debug_cache(
1485 cli: &Cli,
1486 streams: &mut OutputStreams,
1487 executor_opt: Option<&QueryExecutor>,
1488 _stats: &SimpleQueryStats,
1489) -> Result<()> {
1490 if !should_debug_cache(cli) {
1491 return Ok(());
1492 }
1493
1494 let Some(executor) = executor_opt else {
1495 streams.write_diagnostic("CacheStats unavailable in this mode")?;
1496 return Ok(());
1497 };
1498
1499 let (parse_stats, result_stats) = executor.cache_stats();
1500
1501 let debug_line = format!(
1502 "CacheStats{{parse_hits={}, parse_misses={}, result_hits={}, result_misses={}}}",
1503 parse_stats.hits, parse_stats.misses, result_stats.hits, result_stats.misses,
1504 );
1505 streams.write_diagnostic(&debug_line)?;
1506 Ok(())
1507}
1508
1509fn build_hybrid_config(cli: &Cli) -> FallbackConfig {
1511 let mut config = FallbackConfig::from_env();
1512
1513 if cli.no_fallback {
1515 config.fallback_enabled = false;
1516 }
1517
1518 config.text_context_lines = cli.context;
1519 config.max_text_results = cli.max_text_results;
1520
1521 if cli.json {
1523 config.show_search_mode = false;
1524 }
1525
1526 config
1527}
1528
1529fn should_use_hybrid_search(cli: &Cli) -> bool {
1531 if should_debug_cache(cli) {
1533 return false;
1534 }
1535
1536 true
1540}
1541
1542pub(crate) fn create_executor_with_plugins() -> QueryExecutor {
1544 let plugin_manager = crate::plugin_defaults::create_plugin_manager();
1545 QueryExecutor::with_plugin_manager(plugin_manager)
1546}
1547
1548pub(crate) fn create_executor_with_plugins_for_cli(
1549 cli: &Cli,
1550 search_path: &Path,
1551) -> Result<QueryExecutor> {
1552 let effective_root = find_nearest_index(search_path)
1553 .map_or_else(|| search_path.to_path_buf(), |location| location.index_root);
1554 let resolved_plugins = plugin_defaults::resolve_plugin_selection(
1555 cli,
1556 &effective_root,
1557 PluginSelectionMode::ReadOnly,
1558 )?;
1559 Ok(QueryExecutor::with_plugin_manager(
1560 resolved_plugins.plugin_manager,
1561 ))
1562}
1563
1564fn probe_validate_query_syntax(
1614 cli: &Cli,
1615 search_path: &Path,
1616 query_string: &str,
1617 validation_options: ValidationOptions,
1618) -> Result<()> {
1619 let executor = match create_executor_with_plugins_for_cli(cli, search_path) {
1625 Ok(executor) => executor.with_validation_options(validation_options),
1626 Err(_) => create_executor_with_plugins().with_validation_options(validation_options),
1627 };
1628 executor.parse_query_ast(query_string).map(|_| ())
1629}
1630
1631fn validate_query_path_strict(search_path: &Path) -> Result<PathBuf> {
1632 if !search_path.exists() {
1633 bail!(
1634 "invalid path {}: path does not exist",
1635 search_path.display()
1636 );
1637 }
1638 match search_path.canonicalize() {
1639 Ok(canonical) => Ok(canonical),
1640 Err(err) => bail!(
1641 "invalid path {}: path cannot be canonicalized: {err}",
1642 search_path.display()
1643 ),
1644 }
1645}
1646
1647pub(crate) fn acquire_graph_for_cli(cli: &Cli, search_path: &Path) -> Result<GraphAcquisition> {
1675 acquire_graph_for_cli_with_policy(cli, search_path, MissingGraphPolicy::AutoBuildIfEnabled)
1676}
1677
1678pub(crate) fn acquire_graph_for_cli_with_policy(
1682 cli: &Cli,
1683 search_path: &Path,
1684 missing_graph_policy: MissingGraphPolicy,
1685) -> Result<GraphAcquisition> {
1686 let (provider, request) =
1687 build_cli_provider_and_request(cli, search_path, missing_graph_policy)?;
1688 provider.acquire(request).map_err(map_acquisition_error)
1689}
1690
1691pub(crate) fn acquire_graph_for_cli_typed(
1708 cli: &Cli,
1709 search_path: &Path,
1710 missing_graph_policy: MissingGraphPolicy,
1711) -> Result<std::result::Result<GraphAcquisition, GraphAcquisitionError>> {
1712 let (provider, request) =
1713 build_cli_provider_and_request(cli, search_path, missing_graph_policy)?;
1714 Ok(provider.acquire(request))
1715}
1716
1717fn build_cli_provider_and_request(
1723 cli: &Cli,
1724 search_path: &Path,
1725 missing_graph_policy: MissingGraphPolicy,
1726) -> Result<(FilesystemGraphProvider, GraphAcquisitionRequest)> {
1727 let plugin_root = find_nearest_index(search_path)
1737 .map_or_else(|| search_path.to_path_buf(), |location| location.index_root);
1738 let resolved_plugins = plugin_defaults::resolve_plugin_selection(
1739 cli,
1740 &plugin_root,
1741 PluginSelectionMode::ReadOnly,
1742 )?;
1743 let mut provider = FilesystemGraphProvider::new(Arc::new(resolved_plugins.plugin_manager));
1744
1745 if matches!(missing_graph_policy, MissingGraphPolicy::AutoBuildIfEnabled) {
1750 let hook_plugins = plugin_defaults::resolve_plugin_selection(
1758 cli,
1759 &plugin_root,
1760 PluginSelectionMode::ReadOnly,
1761 )?;
1762 let hook_plugin_manager = Arc::new(hook_plugins.plugin_manager);
1763
1764 let auto_build_hook: AutoBuildHook = Arc::new(move |canonical_request: &Path| {
1765 if !is_auto_index_enabled() {
1772 return Err(GraphAcquisitionError::NoGraph {
1773 workspace_root: canonical_request.to_path_buf(),
1774 });
1775 }
1776
1777 log::info!(
1778 "No graph found at {}, auto-building index",
1779 canonical_request.display()
1780 );
1781
1782 let config = sqry_core::graph::unified::build::BuildConfig::default();
1783 let (graph, _build_result) = sqry_core::graph::unified::build::build_and_persist_graph(
1784 canonical_request,
1785 &hook_plugin_manager,
1786 &config,
1787 "cli:auto_index",
1788 )
1789 .map_err(|e| GraphAcquisitionError::BuildFailed {
1790 workspace_root: canonical_request.to_path_buf(),
1791 reason: format!("{e}"),
1792 })?;
1793 Ok(Arc::new(graph))
1794 });
1795
1796 provider = provider.with_auto_build_hook(auto_build_hook);
1797 }
1798
1799 let request = GraphAcquisitionRequest {
1800 requested_path: search_path.to_path_buf(),
1801 operation: AcquisitionOperation::ReadOnlyQuery,
1802 path_policy: PathPolicy::default(),
1803 missing_graph_policy,
1804 stale_policy: StalePolicy::default(),
1805 plugin_selection_policy: PluginSelectionPolicy::default(),
1806 tool_name: Some("sqry_query"),
1807 };
1808 Ok((provider, request))
1809}
1810
1811fn is_auto_index_enabled() -> bool {
1818 match std::env::var("SQRY_AUTO_INDEX") {
1819 Ok(val) => val != "false" && val != "0",
1820 Err(_) => true,
1821 }
1822}
1823
1824fn map_acquisition_error(err: GraphAcquisitionError) -> anyhow::Error {
1828 match err {
1829 GraphAcquisitionError::InvalidPath { path, reason } => {
1830 anyhow::anyhow!("invalid path {}: {}", path.display(), reason)
1831 }
1832 GraphAcquisitionError::NoGraph { workspace_root } => {
1833 anyhow::anyhow!(
1834 "No graph found for {}. Run `sqry index {}` first.",
1835 workspace_root.display(),
1836 workspace_root.display()
1837 )
1838 }
1839 GraphAcquisitionError::IncompatibleGraph {
1840 source_root,
1841 status,
1842 } => match status {
1843 PluginSelectionStatus::IncompatibleUnknownPluginIds { unknown_plugin_ids } => {
1844 anyhow::anyhow!(
1845 "Incompatible graph at {}: manifest references plugin ids unknown to this binary: {}. \
1846 Rebuild the index with `sqry index {} --force` after upgrading sqry.",
1847 source_root.display(),
1848 unknown_plugin_ids.join(", "),
1849 source_root.display()
1850 )
1851 }
1852 PluginSelectionStatus::IncompatibleSnapshotFormat { reason } => anyhow::anyhow!(
1853 "Incompatible graph at {}: {}. Run `sqry index {} --force` to rebuild.",
1854 source_root.display(),
1855 reason,
1856 source_root.display()
1857 ),
1858 PluginSelectionStatus::Exact => {
1859 anyhow::anyhow!(
1860 "Incompatible graph at {} (no detail); rerun `sqry index --force`",
1861 source_root.display()
1862 )
1863 }
1864 },
1865 GraphAcquisitionError::LoadFailed {
1866 source_root,
1867 reason,
1868 } => anyhow::anyhow!(
1869 "Failed to load graph at {}: {}",
1870 source_root.display(),
1871 reason
1872 ),
1873 other => anyhow::anyhow!("graph acquisition failed: {other}"),
1874 }
1875}
1876
1877fn u64_to_f64_lossy(value: u64) -> f64 {
1878 let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
1879 f64::from(narrowed)
1880}
1881
1882fn parse_variable_args(args: &[String]) -> Result<std::collections::HashMap<String, String>> {
1888 let mut map = std::collections::HashMap::new();
1889 for arg in args {
1890 let (key, value) = arg
1891 .split_once('=')
1892 .ok_or_else(|| anyhow::anyhow!("Invalid --var format: '{arg}'. Expected KEY=VALUE"))?;
1893 if key.is_empty() {
1894 bail!("Variable name cannot be empty in --var '{arg}'");
1895 }
1896 map.insert(key.to_string(), value.to_string());
1897 }
1898 Ok(map)
1899}
1900
1901fn is_join_query(query_str: &str) -> bool {
1905 match QueryParser::parse_query(query_str) {
1906 Ok(ast) => matches!(ast.root, Expr::Join(_)),
1907 Err(_) => false,
1908 }
1909}
1910
1911fn detect_pipeline_query(
1917 query_str: &str,
1918) -> Result<Option<sqry_core::query::types::PipelineQuery>> {
1919 match QueryParser::parse_pipeline_query(query_str) {
1920 Ok(result) => Ok(result),
1921 Err(e) => {
1922 if query_str.contains('|') {
1925 Err(anyhow::anyhow!("Pipeline parse error: {e}"))
1926 } else {
1927 Ok(None)
1928 }
1929 }
1930 }
1931}
1932
1933fn run_join_query(
1935 cli: &Cli,
1936 streams: &mut OutputStreams,
1937 query_string: &str,
1938 search_path: &str,
1939 no_parallel: bool,
1940 variables: Option<&std::collections::HashMap<String, String>>,
1941) -> Result<()> {
1942 let validation_options = build_validation_options(cli);
1943 let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
1944 .with_validation_options(validation_options);
1945 if no_parallel {
1946 executor = executor.without_parallel();
1947 }
1948
1949 let resolved_path = Path::new(search_path);
1950 let join_results = executor.execute_join(query_string, resolved_path, variables)?;
1951
1952 if join_results.truncated() {
1953 streams.write_diagnostic(&format!(
1954 "Join query: {} pairs matched via {} (results truncated — cap reached)",
1955 join_results.len(),
1956 join_results.edge_kind()
1957 ))?;
1958 } else {
1959 streams.write_diagnostic(&format!(
1960 "Join query: {} pairs matched via {}",
1961 join_results.len(),
1962 join_results.edge_kind()
1963 ))?;
1964 }
1965
1966 for pair in join_results.iter() {
1967 let left_name = pair.left.name().unwrap_or_default();
1968 let left_path = pair
1969 .left
1970 .relative_path()
1971 .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1972 let right_name = pair.right.name().unwrap_or_default();
1973 let right_path = pair
1974 .right
1975 .relative_path()
1976 .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
1977
1978 if cli.json {
1979 let json = serde_json::json!({
1981 "left": {
1982 "name": left_name.as_ref(),
1983 "kind": pair.left.kind().as_str(),
1984 "path": left_path,
1985 "line": pair.left.start_line(),
1986 },
1987 "edge": pair.edge_kind.to_string(),
1988 "right": {
1989 "name": right_name.as_ref(),
1990 "kind": pair.right.kind().as_str(),
1991 "path": right_path,
1992 "line": pair.right.start_line(),
1993 },
1994 });
1995 streams.write_result(&json.to_string())?;
1996 } else {
1997 streams.write_result(&format!(
1998 "{} ({}:{}) {} {} ({}:{})",
1999 left_name,
2000 left_path,
2001 pair.left.start_line(),
2002 pair.edge_kind,
2003 right_name,
2004 right_path,
2005 pair.right.start_line(),
2006 ))?;
2007 }
2008 }
2009
2010 Ok(())
2011}
2012
2013fn run_pipeline_query(
2015 cli: &Cli,
2016 streams: &mut OutputStreams,
2017 _query_string: &str,
2018 search_path: &str,
2019 pipeline: &sqry_core::query::types::PipelineQuery,
2020 no_parallel: bool,
2021 variables: Option<&std::collections::HashMap<String, String>>,
2022) -> Result<()> {
2023 let validation_options = build_validation_options(cli);
2024 let mut executor = create_executor_with_plugins_for_cli(cli, Path::new(search_path))?
2025 .with_validation_options(validation_options);
2026 if no_parallel {
2027 executor = executor.without_parallel();
2028 }
2029
2030 let resolved_path = Path::new(search_path);
2031
2032 let base_query = sqry_core::query::parsed_query::serialize_query(&pipeline.query);
2035
2036 let results =
2037 executor.execute_on_graph_with_variables(&base_query, resolved_path, variables)?;
2038
2039 for stage in &pipeline.stages {
2041 let aggregation = sqry_core::query::execute_pipeline_stage(&results, stage);
2042
2043 if cli.json {
2044 render_aggregation_json(streams, &aggregation)?;
2045 } else {
2046 streams.write_result(&format!("{aggregation}"))?;
2047 }
2048 }
2049
2050 Ok(())
2051}
2052
2053fn render_aggregation_json(
2055 streams: &mut OutputStreams,
2056 aggregation: &sqry_core::query::pipeline::AggregationResult,
2057) -> Result<()> {
2058 use sqry_core::query::pipeline::AggregationResult;
2059 let json = match aggregation {
2060 AggregationResult::Count(r) => serde_json::json!({
2061 "type": "count",
2062 "total": r.total,
2063 }),
2064 AggregationResult::GroupBy(r) => serde_json::json!({
2065 "type": "group_by",
2066 "field": r.field,
2067 "groups": r.groups.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2068 }),
2069 AggregationResult::Top(r) => serde_json::json!({
2070 "type": "top",
2071 "field": r.field,
2072 "n": r.n,
2073 "entries": r.entries.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2074 }),
2075 AggregationResult::Stats(r) => serde_json::json!({
2076 "type": "stats",
2077 "total": r.total,
2078 "by_kind": r.by_kind.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2079 "by_lang": r.by_lang.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2080 "by_visibility": r.by_visibility.iter().map(|(k, v)| serde_json::json!({"value": k, "count": v})).collect::<Vec<_>>(),
2081 }),
2082 };
2083 streams.write_result(&json.to_string())?;
2084 Ok(())
2085}
2086
2087#[cfg(test)]
2088mod tests {
2089 use super::*;
2090 use sqry_core::relations::CallIdentityKind;
2091
2092 #[test]
2097 fn test_u64_to_f64_lossy_zero() {
2098 assert!((u64_to_f64_lossy(0) - 0.0).abs() < f64::EPSILON);
2099 }
2100
2101 #[test]
2102 fn test_u64_to_f64_lossy_small_values() {
2103 assert!((u64_to_f64_lossy(1) - 1.0).abs() < f64::EPSILON);
2104 assert!((u64_to_f64_lossy(100) - 100.0).abs() < f64::EPSILON);
2105 assert!((u64_to_f64_lossy(1000) - 1000.0).abs() < f64::EPSILON);
2106 }
2107
2108 #[test]
2109 fn test_u64_to_f64_lossy_u32_max() {
2110 let u32_max = u64::from(u32::MAX);
2111 assert!((u64_to_f64_lossy(u32_max) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2112 }
2113
2114 #[test]
2115 fn test_u64_to_f64_lossy_overflow_clamps_to_u32_max() {
2116 let large_value = u64::from(u32::MAX) + 1;
2118 assert!((u64_to_f64_lossy(large_value) - f64::from(u32::MAX)).abs() < f64::EPSILON);
2119 }
2120
2121 #[test]
2126 fn test_format_cache_status_full_hit() {
2127 let status = sqry_core::query::CacheStatus {
2128 parse_cache_hit: true,
2129 result_cache_hit: true,
2130 };
2131 assert_eq!(format_cache_status(&status), "HIT (100% cached)");
2132 }
2133
2134 #[test]
2135 fn test_format_cache_status_parse_hit_only() {
2136 let status = sqry_core::query::CacheStatus {
2137 parse_cache_hit: true,
2138 result_cache_hit: false,
2139 };
2140 assert_eq!(
2141 format_cache_status(&status),
2142 "PARTIAL HIT (query cached, results computed)"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_format_cache_status_result_hit_only() {
2148 let status = sqry_core::query::CacheStatus {
2149 parse_cache_hit: false,
2150 result_cache_hit: true,
2151 };
2152 assert_eq!(
2153 format_cache_status(&status),
2154 "PARTIAL HIT (query parsed, results cached)"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_format_cache_status_full_miss() {
2160 let status = sqry_core::query::CacheStatus {
2161 parse_cache_hit: false,
2162 result_cache_hit: false,
2163 };
2164 assert_eq!(format_cache_status(&status), "MISS (first run)");
2165 }
2166
2167 #[test]
2172 fn test_format_execution_steps_empty() {
2173 let steps: Vec<sqry_core::query::ExecutionStep> = vec![];
2174 assert_eq!(format_execution_steps(&steps), "");
2175 }
2176
2177 #[test]
2178 fn test_format_execution_steps_single() {
2179 let steps = vec![sqry_core::query::ExecutionStep {
2180 step_num: 1,
2181 operation: "Parse query".to_string(),
2182 result_count: 0,
2183 time_ms: 5,
2184 }];
2185 assert_eq!(format_execution_steps(&steps), " 1. Parse query (5ms)");
2186 }
2187
2188 #[test]
2189 fn test_format_execution_steps_multiple() {
2190 let steps = vec![
2191 sqry_core::query::ExecutionStep {
2192 step_num: 1,
2193 operation: "Parse".to_string(),
2194 result_count: 100,
2195 time_ms: 2,
2196 },
2197 sqry_core::query::ExecutionStep {
2198 step_num: 2,
2199 operation: "Optimize".to_string(),
2200 result_count: 50,
2201 time_ms: 3,
2202 },
2203 sqry_core::query::ExecutionStep {
2204 step_num: 3,
2205 operation: "Execute".to_string(),
2206 result_count: 25,
2207 time_ms: 10,
2208 },
2209 ];
2210 let expected = " 1. Parse (2ms)\n 2. Optimize (3ms)\n 3. Execute (10ms)";
2211 assert_eq!(format_execution_steps(&steps), expected);
2212 }
2213
2214 #[test]
2219 fn test_expr_has_repo_predicate_simple_repo() {
2220 let query = QueryParser::parse_query("repo:myrepo").unwrap();
2221 assert!(expr_has_repo_predicate(&query.root));
2222 }
2223
2224 #[test]
2225 fn test_expr_has_repo_predicate_no_repo() {
2226 let query = QueryParser::parse_query("kind:function").unwrap();
2227 assert!(!expr_has_repo_predicate(&query.root));
2228 }
2229
2230 #[test]
2231 fn test_expr_has_repo_predicate_nested_and() {
2232 let query = QueryParser::parse_query("kind:function AND repo:myrepo").unwrap();
2233 assert!(expr_has_repo_predicate(&query.root));
2234 }
2235
2236 #[test]
2237 fn test_expr_has_repo_predicate_nested_or() {
2238 let query = QueryParser::parse_query("kind:function OR repo:myrepo").unwrap();
2239 assert!(expr_has_repo_predicate(&query.root));
2240 }
2241
2242 #[test]
2243 fn test_expr_has_repo_predicate_nested_not() {
2244 let query = QueryParser::parse_query("NOT repo:myrepo").unwrap();
2245 assert!(expr_has_repo_predicate(&query.root));
2246 }
2247
2248 #[test]
2249 fn test_expr_has_repo_predicate_complex_no_repo() {
2250 let query = QueryParser::parse_query("kind:function AND name:foo OR lang:rust").unwrap();
2251 assert!(!expr_has_repo_predicate(&query.root));
2252 }
2253
2254 #[test]
2259 fn test_relation_context_no_relations() {
2260 let ctx = RelationDisplayContext::from_query("kind:function");
2261 assert!(ctx.caller_targets.is_empty());
2262 assert!(ctx.callee_targets.is_empty());
2263 }
2264
2265 #[test]
2266 fn test_relation_context_with_callers() {
2267 let ctx = RelationDisplayContext::from_query("callers:foo");
2268 assert_eq!(ctx.caller_targets, vec!["foo"]);
2269 assert!(ctx.callee_targets.is_empty());
2270 }
2271
2272 #[test]
2273 fn test_relation_context_with_callees() {
2274 let ctx = RelationDisplayContext::from_query("callees:bar");
2275 assert!(ctx.caller_targets.is_empty());
2276 assert_eq!(ctx.callee_targets, vec!["bar"]);
2277 }
2278
2279 #[test]
2280 fn test_relation_context_with_both() {
2281 let ctx = RelationDisplayContext::from_query("callers:foo AND callees:bar");
2282 assert_eq!(ctx.caller_targets, vec!["foo"]);
2283 assert_eq!(ctx.callee_targets, vec!["bar"]);
2284 }
2285
2286 #[test]
2287 fn test_relation_context_invalid_query() {
2288 let ctx = RelationDisplayContext::from_query("invalid query syntax ???");
2290 assert!(ctx.caller_targets.is_empty());
2291 assert!(ctx.callee_targets.is_empty());
2292 }
2293
2294 #[test]
2295 fn test_build_identity_from_qualified_name_preserves_ruby_instance_display() {
2296 let identity = build_identity_from_qualified_name(
2297 "Admin::Users::Controller::show",
2298 "method",
2299 Some("ruby"),
2300 false,
2301 )
2302 .expect("ruby instance identity");
2303
2304 assert_eq!(identity.qualified, "Admin::Users::Controller#show");
2305 assert_eq!(identity.method_kind, CallIdentityKind::Instance);
2306 }
2307
2308 #[test]
2309 fn test_build_identity_from_qualified_name_preserves_ruby_singleton_display() {
2310 let identity = build_identity_from_qualified_name(
2311 "Admin::Users::Controller::show",
2312 "method",
2313 Some("ruby"),
2314 true,
2315 )
2316 .expect("ruby singleton identity");
2317
2318 assert_eq!(identity.qualified, "Admin::Users::Controller.show");
2319 assert_eq!(identity.method_kind, CallIdentityKind::Singleton);
2320 }
2321
2322 #[test]
2327 fn test_ensure_repo_not_present_ok() {
2328 let result = ensure_repo_predicate_not_present("kind:function");
2329 assert!(result.is_ok());
2330 }
2331
2332 #[test]
2333 fn test_ensure_repo_not_present_fails_with_repo() {
2334 let result = ensure_repo_predicate_not_present("repo:myrepo");
2335 assert!(result.is_err());
2336 assert!(
2337 result
2338 .unwrap_err()
2339 .to_string()
2340 .contains("repo: filters are only supported")
2341 );
2342 }
2343
2344 #[test]
2345 fn test_ensure_repo_not_present_fails_with_nested_repo() {
2346 let result = ensure_repo_predicate_not_present("kind:function AND repo:myrepo");
2347 assert!(result.is_err());
2348 }
2349
2350 #[test]
2351 fn test_ensure_repo_not_present_fallback_text_check() {
2352 let result = ensure_repo_predicate_not_present("invalid??? repo:something");
2354 assert!(result.is_err());
2355 }
2356
2357 #[test]
2362 fn test_parse_variable_args_empty() {
2363 let result = parse_variable_args(&[]).unwrap();
2364 assert!(result.is_empty());
2365 }
2366
2367 #[test]
2368 fn test_parse_variable_args_single_key_value() {
2369 let args = vec!["FOO=bar".to_string()];
2370 let result = parse_variable_args(&args).unwrap();
2371 assert_eq!(result.len(), 1);
2372 assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
2373 }
2374
2375 #[test]
2376 fn test_parse_variable_args_multiple() {
2377 let args = vec!["A=1".to_string(), "B=hello world".to_string()];
2378 let result = parse_variable_args(&args).unwrap();
2379 assert_eq!(result.len(), 2);
2380 assert_eq!(result.get("A"), Some(&"1".to_string()));
2381 assert_eq!(result.get("B"), Some(&"hello world".to_string()));
2382 }
2383
2384 #[test]
2385 fn test_parse_variable_args_value_with_equals() {
2386 let args = vec!["KEY=val=ue".to_string()];
2388 let result = parse_variable_args(&args).unwrap();
2389 assert_eq!(result.get("KEY"), Some(&"val=ue".to_string()));
2390 }
2391
2392 #[test]
2393 fn test_parse_variable_args_no_equals_errors() {
2394 let args = vec!["NOEQUALS".to_string()];
2395 let err = parse_variable_args(&args).unwrap_err();
2396 assert!(
2397 err.to_string().contains("Invalid --var format"),
2398 "Unexpected error: {err}"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_parse_variable_args_empty_key_errors() {
2404 let args = vec!["=value".to_string()];
2405 let err = parse_variable_args(&args).unwrap_err();
2406 assert!(
2407 err.to_string().contains("Variable name cannot be empty"),
2408 "Unexpected error: {err}"
2409 );
2410 }
2411
2412 #[test]
2413 fn test_parse_variable_args_empty_value_allowed() {
2414 let args = vec!["KEY=".to_string()];
2415 let result = parse_variable_args(&args).unwrap();
2416 assert_eq!(result.get("KEY"), Some(&String::new()));
2417 }
2418
2419 #[test]
2424 fn test_is_join_query_non_join() {
2425 assert!(!is_join_query("kind:function"));
2426 assert!(!is_join_query("name:foo AND kind:method"));
2427 }
2428
2429 #[test]
2430 fn test_is_join_query_invalid_query_returns_false() {
2431 assert!(!is_join_query("invalid ??? syntax {{{"));
2433 }
2434
2435 #[test]
2436 fn test_is_join_query_positive() {
2437 assert!(
2440 is_join_query("(kind:function) CALLS (kind:function)"),
2441 "CALLS join expression must be detected as a join query"
2442 );
2443 }
2444
2445 #[test]
2450 fn test_detect_pipeline_query_no_pipe_returns_none() {
2451 let result = detect_pipeline_query("kind:function").unwrap();
2452 assert!(result.is_none());
2453 }
2454
2455 #[test]
2456 fn test_detect_pipeline_query_invalid_without_pipe_returns_none() {
2457 let result = detect_pipeline_query("invalid query !!!").unwrap();
2459 assert!(result.is_none());
2460 }
2461
2462 #[test]
2463 fn test_detect_pipeline_query_invalid_with_pipe_errors() {
2464 let result = detect_pipeline_query("kind:function | count");
2468 assert!(
2469 result.is_ok(),
2470 "A valid pipeline query must return Ok, got: {result:?}"
2471 );
2472 assert!(
2473 result.unwrap().is_some(),
2474 "A valid pipeline query must return Ok(Some(_))"
2475 );
2476 }
2477
2478 #[test]
2483 fn test_apply_symbol_limit_no_truncation() {
2484 let mut symbols: Vec<DisplaySymbol> = (0..5)
2485 .map(|i| DisplaySymbol {
2486 name: format!("sym{i}"),
2487 qualified_name: format!("sym{i}"),
2488 kind: "function".to_string(),
2489 file_path: std::path::PathBuf::from("a.rs"),
2490 start_line: i,
2491 start_column: 0,
2492 end_line: i,
2493 end_column: 0,
2494 metadata: std::collections::HashMap::new(),
2495 caller_identity: None,
2496 callee_identity: None,
2497 })
2498 .collect();
2499
2500 let info = apply_symbol_limit(&mut symbols, 10);
2501 assert_eq!(symbols.len(), 5);
2502 assert!(!info.truncated);
2503 assert_eq!(info.total_matches, 5);
2504 assert_eq!(info.limit, 10);
2505 }
2506
2507 #[test]
2508 fn test_apply_symbol_limit_truncates() {
2509 let mut symbols: Vec<DisplaySymbol> = (0..20)
2510 .map(|i| DisplaySymbol {
2511 name: format!("sym{i}"),
2512 qualified_name: format!("sym{i}"),
2513 kind: "function".to_string(),
2514 file_path: std::path::PathBuf::from("a.rs"),
2515 start_line: i,
2516 start_column: 0,
2517 end_line: i,
2518 end_column: 0,
2519 metadata: std::collections::HashMap::new(),
2520 caller_identity: None,
2521 callee_identity: None,
2522 })
2523 .collect();
2524
2525 let info = apply_symbol_limit(&mut symbols, 5);
2526 assert_eq!(symbols.len(), 5);
2527 assert!(info.truncated);
2528 assert_eq!(info.total_matches, 20);
2529 assert_eq!(info.limit, 5);
2530 }
2531
2532 #[test]
2533 fn test_apply_symbol_limit_exact_boundary() {
2534 let mut symbols: Vec<DisplaySymbol> = (0..5)
2535 .map(|i| DisplaySymbol {
2536 name: format!("sym{i}"),
2537 qualified_name: format!("sym{i}"),
2538 kind: "function".to_string(),
2539 file_path: std::path::PathBuf::from("a.rs"),
2540 start_line: i,
2541 start_column: 0,
2542 end_line: i,
2543 end_column: 0,
2544 metadata: std::collections::HashMap::new(),
2545 caller_identity: None,
2546 callee_identity: None,
2547 })
2548 .collect();
2549
2550 let info = apply_symbol_limit(&mut symbols, 5);
2551 assert_eq!(symbols.len(), 5);
2552 assert!(!info.truncated, "Exact boundary should not truncate");
2553 }
2554
2555 #[test]
2560 fn test_u64_to_f64_lossy_large_values_clamp_to_u32_max() {
2561 let very_large = u64::MAX;
2562 let result = u64_to_f64_lossy(very_large);
2563 assert!((result - f64::from(u32::MAX)).abs() < f64::EPSILON);
2565 }
2566
2567 #[serial_test::serial]
2572 #[test]
2573 fn test_env_debug_cache_disabled_by_default() {
2574 unsafe {
2577 std::env::remove_var("SQRY_CACHE_DEBUG");
2578 }
2579 assert!(!env_debug_cache_enabled());
2580 }
2581
2582 #[serial_test::serial]
2583 #[test]
2584 fn test_env_debug_cache_enabled_with_1() {
2585 unsafe {
2586 std::env::set_var("SQRY_CACHE_DEBUG", "1");
2587 }
2588 let result = env_debug_cache_enabled();
2589 unsafe {
2590 std::env::remove_var("SQRY_CACHE_DEBUG");
2591 }
2592 assert!(result);
2593 }
2594
2595 #[serial_test::serial]
2596 #[test]
2597 fn test_env_debug_cache_enabled_with_true() {
2598 unsafe {
2599 std::env::set_var("SQRY_CACHE_DEBUG", "true");
2600 }
2601 let result = env_debug_cache_enabled();
2602 unsafe {
2603 std::env::remove_var("SQRY_CACHE_DEBUG");
2604 }
2605 assert!(result);
2606 }
2607
2608 #[serial_test::serial]
2609 #[test]
2610 fn test_env_debug_cache_enabled_with_true_uppercase() {
2611 unsafe {
2612 std::env::set_var("SQRY_CACHE_DEBUG", "TRUE");
2613 }
2614 let result = env_debug_cache_enabled();
2615 unsafe {
2616 std::env::remove_var("SQRY_CACHE_DEBUG");
2617 }
2618 assert!(result);
2619 }
2620
2621 #[serial_test::serial]
2622 #[test]
2623 fn test_env_debug_cache_disabled_with_zero() {
2624 unsafe {
2625 std::env::set_var("SQRY_CACHE_DEBUG", "0");
2626 }
2627 let result = env_debug_cache_enabled();
2628 unsafe {
2629 std::env::remove_var("SQRY_CACHE_DEBUG");
2630 }
2631 assert!(!result);
2632 }
2633
2634 #[test]
2639 fn test_build_query_stats_with_index() {
2640 let stats = build_query_stats(true, 10);
2641 assert!(stats.used_index);
2642 }
2643
2644 #[test]
2645 fn test_build_query_stats_without_index() {
2646 let stats = build_query_stats(false, 10);
2647 assert!(!stats.used_index);
2648 }
2649}