1use super::classifier::{QueryClassifier, QueryType};
10use super::{Match as TextMatch, SearchConfig, SearchMode, Searcher as TextSearcher};
11use crate::graph::CodeGraph;
12use crate::query::QueryExecutor;
13use crate::query::results::QueryResults;
14use anyhow::{Context, Error, Result, anyhow};
15use log::error;
16use std::path::Path;
17use std::sync::Arc;
18
19#[derive(Debug, Clone)]
21pub struct FallbackConfig {
22 pub fallback_enabled: bool,
24
25 pub min_semantic_results: usize,
28
29 pub text_context_lines: usize,
31
32 pub max_text_results: usize,
34
35 pub show_search_mode: bool,
37}
38
39impl Default for FallbackConfig {
40 fn default() -> Self {
41 Self {
42 fallback_enabled: true,
43 min_semantic_results: 1,
44 text_context_lines: 2,
45 max_text_results: 1000,
46 show_search_mode: true,
47 }
48 }
49}
50
51impl FallbackConfig {
52 #[must_use]
61 pub fn from_env() -> Self {
62 let mut config = Self::default();
63
64 if let Ok(val) = std::env::var("SQRY_FALLBACK_ENABLED") {
65 config.fallback_enabled = val.parse().unwrap_or(true);
66 }
67
68 if let Ok(val) = std::env::var("SQRY_MIN_SEMANTIC_RESULTS") {
69 config.min_semantic_results = val.parse().unwrap_or(1);
70 }
71
72 if let Ok(val) = std::env::var("SQRY_TEXT_CONTEXT_LINES") {
73 config.text_context_lines = val.parse().unwrap_or(2);
74 }
75
76 if let Ok(val) = std::env::var("SQRY_MAX_TEXT_RESULTS") {
77 config.max_text_results = val.parse().unwrap_or(1000);
78 }
79
80 if let Ok(val) = std::env::var("SQRY_SHOW_SEARCH_MODE") {
81 config.show_search_mode = val.parse().unwrap_or(true);
82 }
83
84 config
85 }
86}
87
88#[derive(Debug)]
90pub enum SearchResults {
91 Semantic {
93 results: QueryResults,
95 mode: SearchModeUsed,
97 },
98
99 Text {
101 matches: Vec<TextMatch>,
103 mode: SearchModeUsed,
105 },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SearchModeUsed {
111 SemanticOnly,
113
114 TextOnly,
116
117 SemanticSucceeded,
119
120 SemanticFallbackToText,
122
123 Combined,
125}
126
127impl SearchResults {
128 #[must_use]
130 pub fn len(&self) -> usize {
131 match self {
132 SearchResults::Semantic { results, .. } => results.len(),
133 SearchResults::Text { matches, .. } => matches.len(),
134 }
135 }
136
137 #[must_use]
139 pub fn is_empty(&self) -> bool {
140 self.len() == 0
141 }
142
143 #[must_use]
145 pub fn mode(&self) -> SearchModeUsed {
146 match self {
147 SearchResults::Semantic { mode, .. } | SearchResults::Text { mode, .. } => *mode,
148 }
149 }
150}
151
152pub struct FallbackSearchEngine {
154 query_executor: QueryExecutor,
156
157 text_searcher: Option<TextSearcher>,
159
160 text_search_error: Option<String>,
162
163 config: FallbackConfig,
165}
166
167impl FallbackSearchEngine {
168 fn from_parts(
169 query_executor: QueryExecutor,
170 text_searcher: Option<TextSearcher>,
171 text_search_error: Option<String>,
172 config: FallbackConfig,
173 ) -> Self {
174 Self {
175 query_executor,
176 text_searcher,
177 text_search_error,
178 config,
179 }
180 }
181
182 fn without_text_search(
183 query_executor: QueryExecutor,
184 config: FallbackConfig,
185 error: &Error,
186 ) -> Self {
187 Self::from_parts(query_executor, None, Some(format!("{error:#}")), config)
188 }
189
190 fn text_searcher(&self) -> Result<&TextSearcher> {
191 self.text_searcher.as_ref().ok_or_else(|| {
192 let reason = self
193 .text_search_error
194 .as_deref()
195 .unwrap_or("text searcher initialization failed");
196 anyhow!("Text search is unavailable ({reason})")
197 })
198 }
199
200 pub fn new() -> Result<Self> {
207 Self::with_config(FallbackConfig::default())
208 }
209
210 pub fn with_config(config: FallbackConfig) -> Result<Self> {
222 Self::with_config_and_executor(config, QueryExecutor::new())
223 }
224
225 pub fn with_config_and_executor(
255 config: FallbackConfig,
256 query_executor: QueryExecutor,
257 ) -> Result<Self> {
258 let text_searcher =
259 TextSearcher::new().context("Failed to create text searcher for hybrid engine")?;
260
261 Ok(Self::from_parts(
262 query_executor,
263 Some(text_searcher),
264 None,
265 config,
266 ))
267 }
268
269 pub fn search(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
292 let query_type = QueryClassifier::classify(query);
294
295 if self.config.show_search_mode {
296 match query_type {
297 QueryType::Semantic => log::debug!("[Semantic search mode]"),
298 QueryType::Text => log::debug!("[Text search mode]"),
299 QueryType::Hybrid => log::debug!("[Hybrid mode: trying semantic first...]"),
300 }
301 }
302
303 match query_type {
305 QueryType::Semantic => self.search_semantic_only(query, path),
306 QueryType::Text => self.search_text_only(query, path),
307 QueryType::Hybrid => self.search_hybrid(query, path),
308 }
309 }
310
311 pub fn search_semantic_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
318 let results = self.query_executor.execute_on_graph(query, path)?;
320
321 Ok(SearchResults::Semantic {
322 results,
323 mode: SearchModeUsed::SemanticOnly,
324 })
325 }
326
327 pub fn search_semantic_only_with_preloaded_graph(
349 &mut self,
350 query: &str,
351 graph: Arc<CodeGraph>,
352 scope_path: &Path,
353 ) -> Result<SearchResults> {
354 let results = self
355 .query_executor
356 .execute_on_preloaded_graph(graph, query, scope_path, None)?;
357
358 Ok(SearchResults::Semantic {
359 results,
360 mode: SearchModeUsed::SemanticOnly,
361 })
362 }
363
364 pub fn search_text_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
371 let config = SearchConfig {
372 mode: SearchMode::Regex,
373 case_insensitive: false,
374 include_hidden: false,
375 follow_symlinks: false,
376 max_depth: None,
377 file_types: Vec::new(),
378 exclude_patterns: Vec::new(),
379 before_context: self.config.text_context_lines,
380 after_context: self.config.text_context_lines,
381 };
382
383 let searcher = self
384 .text_searcher()
385 .context("Text search unavailable in hybrid engine")?;
386
387 let matches = searcher
388 .search(query, &[path], &config)
389 .context("Text search failed")?;
390
391 let matches = matches
393 .into_iter()
394 .take(self.config.max_text_results)
395 .collect();
396
397 Ok(SearchResults::Text {
398 matches,
399 mode: SearchModeUsed::TextOnly,
400 })
401 }
402
403 pub fn search_with_preloaded_graph(
415 &mut self,
416 query: &str,
417 graph: Arc<CodeGraph>,
418 scope_path: &Path,
419 ) -> Result<SearchResults> {
420 let query_type = QueryClassifier::classify(query);
421
422 if self.config.show_search_mode {
423 match query_type {
424 QueryType::Semantic => log::debug!("[Semantic search mode]"),
425 QueryType::Text => log::debug!("[Text search mode]"),
426 QueryType::Hybrid => log::debug!("[Hybrid mode: trying semantic first...]"),
427 }
428 }
429
430 match query_type {
431 QueryType::Semantic => {
432 self.search_semantic_only_with_preloaded_graph(query, graph, scope_path)
433 }
434 QueryType::Text => self.search_text_only(query, scope_path),
435 QueryType::Hybrid => self.search_hybrid_with_preloaded_graph(query, graph, scope_path),
436 }
437 }
438
439 fn search_hybrid(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
441 let semantic_result = self.query_executor.execute_on_graph(query, path);
443
444 match semantic_result {
445 Ok(results) if results.len() >= self.config.min_semantic_results => {
446 if self.config.show_search_mode {
448 log::debug!("[Semantic search: {} results]", results.len());
449 }
450
451 Ok(SearchResults::Semantic {
452 results,
453 mode: SearchModeUsed::SemanticSucceeded,
454 })
455 }
456
457 Ok(results) if self.config.fallback_enabled => {
458 if self.config.show_search_mode {
460 log::debug!(
461 "[Semantic search: {} results (below threshold {})]",
462 results.len(),
463 self.config.min_semantic_results
464 );
465 log::debug!("[Falling back to text search...]");
466 }
467
468 let config = SearchConfig {
469 mode: SearchMode::Regex,
470 case_insensitive: false,
471 include_hidden: false,
472 follow_symlinks: false,
473 max_depth: None,
474 file_types: Vec::new(),
475 exclude_patterns: Vec::new(),
476 before_context: self.config.text_context_lines,
477 after_context: self.config.text_context_lines,
478 };
479
480 let searcher = self
481 .text_searcher()
482 .context("Text search unavailable during fallback")?;
483 let matches = searcher
484 .search(query, &[path], &config)
485 .context("Text search failed during fallback")?;
486
487 let matches = matches
488 .into_iter()
489 .take(self.config.max_text_results)
490 .collect::<Vec<_>>();
491
492 if self.config.show_search_mode {
493 log::debug!("[Text search: {} results]", matches.len());
494 }
495
496 Ok(SearchResults::Text {
497 matches,
498 mode: SearchModeUsed::SemanticFallbackToText,
499 })
500 }
501
502 Ok(results) => {
503 Ok(SearchResults::Semantic {
505 results,
506 mode: SearchModeUsed::SemanticOnly,
507 })
508 }
509
510 Err(e) if self.config.fallback_enabled => {
511 if self.config.show_search_mode {
513 log::debug!("[Semantic search failed: {e}]");
514 log::debug!("[Falling back to text search...]");
515 }
516
517 let config = SearchConfig {
518 mode: SearchMode::Regex,
519 case_insensitive: false,
520 include_hidden: false,
521 follow_symlinks: false,
522 max_depth: None,
523 file_types: Vec::new(),
524 exclude_patterns: Vec::new(),
525 before_context: self.config.text_context_lines,
526 after_context: self.config.text_context_lines,
527 };
528
529 let searcher = self
530 .text_searcher()
531 .context("Text search unavailable during fallback")?;
532 let matches = searcher
533 .search(query, &[path], &config)
534 .context("Text search failed during fallback")?;
535
536 let matches = matches
537 .into_iter()
538 .take(self.config.max_text_results)
539 .collect();
540
541 Ok(SearchResults::Text {
542 matches,
543 mode: SearchModeUsed::SemanticFallbackToText,
544 })
545 }
546
547 Err(e) => {
548 Err(e)
550 }
551 }
552 }
553
554 fn search_hybrid_with_preloaded_graph(
566 &mut self,
567 query: &str,
568 graph: Arc<CodeGraph>,
569 path: &Path,
570 ) -> Result<SearchResults> {
571 let semantic_result = self
572 .query_executor
573 .execute_on_preloaded_graph(graph, query, path, None);
574
575 match semantic_result {
576 Ok(results) if results.len() >= self.config.min_semantic_results => {
577 if self.config.show_search_mode {
578 log::debug!("[Semantic search: {} results]", results.len());
579 }
580
581 Ok(SearchResults::Semantic {
582 results,
583 mode: SearchModeUsed::SemanticSucceeded,
584 })
585 }
586
587 Ok(results) if self.config.fallback_enabled => {
588 if self.config.show_search_mode {
589 log::debug!(
590 "[Semantic search: {} results (below threshold {})]",
591 results.len(),
592 self.config.min_semantic_results
593 );
594 log::debug!("[Falling back to text search...]");
595 }
596
597 self.text_fallback(query, path, SearchModeUsed::SemanticFallbackToText)
598 }
599
600 Ok(results) => Ok(SearchResults::Semantic {
601 results,
602 mode: SearchModeUsed::SemanticOnly,
603 }),
604
605 Err(e) if self.config.fallback_enabled => {
606 if self.config.show_search_mode {
607 log::debug!("[Semantic search failed: {e}]");
608 log::debug!("[Falling back to text search...]");
609 }
610
611 self.text_fallback(query, path, SearchModeUsed::SemanticFallbackToText)
612 }
613
614 Err(e) => Err(e),
615 }
616 }
617
618 fn text_fallback(
622 &self,
623 query: &str,
624 path: &Path,
625 mode: SearchModeUsed,
626 ) -> Result<SearchResults> {
627 let config = SearchConfig {
628 mode: SearchMode::Regex,
629 case_insensitive: false,
630 include_hidden: false,
631 follow_symlinks: false,
632 max_depth: None,
633 file_types: Vec::new(),
634 exclude_patterns: Vec::new(),
635 before_context: self.config.text_context_lines,
636 after_context: self.config.text_context_lines,
637 };
638
639 let searcher = self
640 .text_searcher()
641 .context("Text search unavailable during fallback")?;
642 let matches = searcher
643 .search(query, &[path], &config)
644 .context("Text search failed during fallback")?;
645
646 let matches = matches
647 .into_iter()
648 .take(self.config.max_text_results)
649 .collect::<Vec<_>>();
650
651 if self.config.show_search_mode {
652 log::debug!("[Text search: {} results]", matches.len());
653 }
654
655 Ok(SearchResults::Text { matches, mode })
656 }
657}
658
659impl Default for FallbackSearchEngine {
660 fn default() -> Self {
661 let config = FallbackConfig::default();
662 let query_executor = QueryExecutor::new();
663 match TextSearcher::new() {
664 Ok(searcher) => Self::from_parts(query_executor, Some(searcher), None, config),
665 Err(err) => {
666 error!(
667 "FallbackSearchEngine default initialization failed; text search disabled: {err:#}"
668 );
669 Self::without_text_search(query_executor, config, &err)
670 }
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use std::fs;
679 use tempfile::TempDir;
680
681 fn setup_test_dir() -> TempDir {
682 let dir = TempDir::new().unwrap();
683 let test_file = dir.path().join("test.rs");
684 fs::write(
685 &test_file,
686 r#"
687pub fn foo() {
688 // TODO: add more functionality
689 println!("hello");
690}
691
692// bar intentionally simplified for fixture use
693fn bar() {
694 // Stubbed for test fixture coverage only
695}
696"#,
697 )
698 .unwrap();
699
700 dir
701 }
702
703 #[test]
704 fn test_hybrid_config_default() {
705 let config = FallbackConfig::default();
706 assert!(config.fallback_enabled);
707 assert_eq!(config.min_semantic_results, 1);
708 assert_eq!(config.text_context_lines, 2);
709 assert_eq!(config.max_text_results, 1000);
710 }
711
712 #[test]
713 fn test_hybrid_config_from_env() {
714 unsafe {
715 std::env::set_var("SQRY_FALLBACK_ENABLED", "false");
716 std::env::set_var("SQRY_MIN_SEMANTIC_RESULTS", "5");
717 std::env::set_var("SQRY_TEXT_CONTEXT_LINES", "3");
718 }
719
720 let config = FallbackConfig::from_env();
721 assert!(!config.fallback_enabled);
722 assert_eq!(config.min_semantic_results, 5);
723 assert_eq!(config.text_context_lines, 3);
724
725 unsafe {
727 std::env::remove_var("SQRY_FALLBACK_ENABLED");
728 std::env::remove_var("SQRY_MIN_SEMANTIC_RESULTS");
729 std::env::remove_var("SQRY_TEXT_CONTEXT_LINES");
730 }
731 }
732
733 #[test]
734 fn test_search_text_only() {
735 let dir = setup_test_dir();
736 let mut engine = FallbackSearchEngine::new().unwrap();
737
738 let results = engine.search_text_only("TODO", dir.path()).unwrap();
739
740 match results {
741 SearchResults::Text { matches, mode } => {
742 assert!(!matches.is_empty());
743 assert_eq!(mode, SearchModeUsed::TextOnly);
744 }
745 SearchResults::Semantic { .. } => panic!("Expected Text results"),
746 }
747 }
748
749 #[test]
750 fn test_search_results_len() {
751 let dir = setup_test_dir();
752 let mut engine = FallbackSearchEngine::new().unwrap();
753
754 let results = engine.search_text_only("TODO", dir.path()).unwrap();
755 assert!(!results.is_empty());
756 }
757
758 #[test]
759 fn test_hybrid_fallback_disabled() {
760 let dir = setup_test_dir();
761 let config = FallbackConfig {
762 fallback_enabled: false,
763 ..Default::default()
764 };
765
766 let mut engine = FallbackSearchEngine::with_config(config).unwrap();
767
768 let result = engine.search_hybrid("nonexistent", dir.path());
770
771 if let Ok(SearchResults::Semantic { results, .. }) = result {
773 assert_eq!(results.len(), 0);
774 }
775 }
776
777 #[test]
788 fn semantic_only_with_preloaded_graph_uses_caller_graph() {
789 let dir = TempDir::new().unwrap();
790 let mut engine = FallbackSearchEngine::new().unwrap();
793 let graph = Arc::new(CodeGraph::new());
794
795 let results = engine
796 .search_semantic_only_with_preloaded_graph("kind:function", graph, dir.path())
797 .expect("preloaded-graph entrypoint must not consult on-disk graph");
798
799 match results {
800 SearchResults::Semantic { results, mode } => {
801 assert_eq!(results.len(), 0, "empty graph yields zero matches");
802 assert_eq!(mode, SearchModeUsed::SemanticOnly);
803 }
804 SearchResults::Text { .. } => {
805 panic!("semantic-only entrypoint must not return text results")
806 }
807 }
808 }
809
810 #[test]
816 fn search_with_preloaded_graph_routes_semantic_class_to_preloaded_executor() {
817 let dir = TempDir::new().unwrap();
818 let mut engine = FallbackSearchEngine::new().unwrap();
819 let graph = Arc::new(CodeGraph::new());
820
821 let results = engine
822 .search_with_preloaded_graph("kind:function", graph, dir.path())
823 .expect("preloaded-graph entrypoint must not consult on-disk graph");
824
825 match results {
826 SearchResults::Semantic { results, mode } => {
827 assert_eq!(results.len(), 0);
828 assert_eq!(mode, SearchModeUsed::SemanticOnly);
829 }
830 SearchResults::Text { .. } => panic!("semantic-class query must not fall back to text"),
831 }
832 }
833}