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