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