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