1use super::classifier::{QueryClassifier, QueryType};
10use super::{Match as TextMatch, SearchConfig, SearchMode, Searcher as TextSearcher};
11use crate::query::QueryExecutor;
12use crate::query::results::QueryResults;
13use anyhow::{Context, Error, Result, anyhow};
14use log::error;
15use std::path::Path;
16
17#[derive(Debug, Clone)]
19pub struct FallbackConfig {
20 pub fallback_enabled: bool,
22
23 pub min_semantic_results: usize,
26
27 pub text_context_lines: usize,
29
30 pub max_text_results: usize,
32
33 pub show_search_mode: bool,
35}
36
37impl Default for FallbackConfig {
38 fn default() -> Self {
39 Self {
40 fallback_enabled: true,
41 min_semantic_results: 1,
42 text_context_lines: 2,
43 max_text_results: 1000,
44 show_search_mode: true,
45 }
46 }
47}
48
49impl FallbackConfig {
50 #[must_use]
59 pub fn from_env() -> Self {
60 let mut config = Self::default();
61
62 if let Ok(val) = std::env::var("SQRY_FALLBACK_ENABLED") {
63 config.fallback_enabled = val.parse().unwrap_or(true);
64 }
65
66 if let Ok(val) = std::env::var("SQRY_MIN_SEMANTIC_RESULTS") {
67 config.min_semantic_results = val.parse().unwrap_or(1);
68 }
69
70 if let Ok(val) = std::env::var("SQRY_TEXT_CONTEXT_LINES") {
71 config.text_context_lines = val.parse().unwrap_or(2);
72 }
73
74 if let Ok(val) = std::env::var("SQRY_MAX_TEXT_RESULTS") {
75 config.max_text_results = val.parse().unwrap_or(1000);
76 }
77
78 if let Ok(val) = std::env::var("SQRY_SHOW_SEARCH_MODE") {
79 config.show_search_mode = val.parse().unwrap_or(true);
80 }
81
82 config
83 }
84}
85
86#[derive(Debug)]
88pub enum SearchResults {
89 Semantic {
91 results: QueryResults,
93 mode: SearchModeUsed,
95 },
96
97 Text {
99 matches: Vec<TextMatch>,
101 mode: SearchModeUsed,
103 },
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum SearchModeUsed {
109 SemanticOnly,
111
112 TextOnly,
114
115 SemanticSucceeded,
117
118 SemanticFallbackToText,
120
121 Combined,
123}
124
125impl SearchResults {
126 #[must_use]
128 pub fn len(&self) -> usize {
129 match self {
130 SearchResults::Semantic { results, .. } => results.len(),
131 SearchResults::Text { matches, .. } => matches.len(),
132 }
133 }
134
135 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 self.len() == 0
139 }
140
141 #[must_use]
143 pub fn mode(&self) -> SearchModeUsed {
144 match self {
145 SearchResults::Semantic { mode, .. } | SearchResults::Text { mode, .. } => *mode,
146 }
147 }
148}
149
150pub struct FallbackSearchEngine {
152 query_executor: QueryExecutor,
154
155 text_searcher: Option<TextSearcher>,
157
158 text_search_error: Option<String>,
160
161 config: FallbackConfig,
163}
164
165impl FallbackSearchEngine {
166 fn from_parts(
167 query_executor: QueryExecutor,
168 text_searcher: Option<TextSearcher>,
169 text_search_error: Option<String>,
170 config: FallbackConfig,
171 ) -> Self {
172 Self {
173 query_executor,
174 text_searcher,
175 text_search_error,
176 config,
177 }
178 }
179
180 fn without_text_search(
181 query_executor: QueryExecutor,
182 config: FallbackConfig,
183 error: &Error,
184 ) -> Self {
185 Self::from_parts(query_executor, None, Some(format!("{error:#}")), config)
186 }
187
188 fn text_searcher(&self) -> Result<&TextSearcher> {
189 self.text_searcher.as_ref().ok_or_else(|| {
190 let reason = self
191 .text_search_error
192 .as_deref()
193 .unwrap_or("text searcher initialization failed");
194 anyhow!("Text search is unavailable ({reason})")
195 })
196 }
197
198 pub fn new() -> Result<Self> {
205 Self::with_config(FallbackConfig::default())
206 }
207
208 pub fn with_config(config: FallbackConfig) -> Result<Self> {
220 Self::with_config_and_executor(config, QueryExecutor::new())
221 }
222
223 pub fn with_config_and_executor(
253 config: FallbackConfig,
254 query_executor: QueryExecutor,
255 ) -> Result<Self> {
256 let text_searcher =
257 TextSearcher::new().context("Failed to create text searcher for hybrid engine")?;
258
259 Ok(Self::from_parts(
260 query_executor,
261 Some(text_searcher),
262 None,
263 config,
264 ))
265 }
266
267 pub fn search(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
290 let query_type = QueryClassifier::classify(query);
292
293 if self.config.show_search_mode {
294 match query_type {
295 QueryType::Semantic => log::debug!("[Semantic search mode]"),
296 QueryType::Text => log::debug!("[Text search mode]"),
297 QueryType::Hybrid => log::debug!("[Hybrid mode: trying semantic first...]"),
298 }
299 }
300
301 match query_type {
303 QueryType::Semantic => self.search_semantic_only(query, path),
304 QueryType::Text => self.search_text_only(query, path),
305 QueryType::Hybrid => self.search_hybrid(query, path),
306 }
307 }
308
309 pub fn search_semantic_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
316 let results = self.query_executor.execute_on_graph(query, path)?;
318
319 Ok(SearchResults::Semantic {
320 results,
321 mode: SearchModeUsed::SemanticOnly,
322 })
323 }
324
325 pub fn search_text_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
332 let config = SearchConfig {
333 mode: SearchMode::Regex,
334 case_insensitive: false,
335 include_hidden: false,
336 follow_symlinks: false,
337 max_depth: None,
338 file_types: Vec::new(),
339 exclude_patterns: Vec::new(),
340 before_context: self.config.text_context_lines,
341 after_context: self.config.text_context_lines,
342 };
343
344 let searcher = self
345 .text_searcher()
346 .context("Text search unavailable in hybrid engine")?;
347
348 let matches = searcher
349 .search(query, &[path], &config)
350 .context("Text search failed")?;
351
352 let matches = matches
354 .into_iter()
355 .take(self.config.max_text_results)
356 .collect();
357
358 Ok(SearchResults::Text {
359 matches,
360 mode: SearchModeUsed::TextOnly,
361 })
362 }
363
364 fn search_hybrid(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
366 let semantic_result = self.query_executor.execute_on_graph(query, path);
368
369 match semantic_result {
370 Ok(results) if results.len() >= self.config.min_semantic_results => {
371 if self.config.show_search_mode {
373 log::debug!("[Semantic search: {} results]", results.len());
374 }
375
376 Ok(SearchResults::Semantic {
377 results,
378 mode: SearchModeUsed::SemanticSucceeded,
379 })
380 }
381
382 Ok(results) if self.config.fallback_enabled => {
383 if self.config.show_search_mode {
385 log::debug!(
386 "[Semantic search: {} results (below threshold {})]",
387 results.len(),
388 self.config.min_semantic_results
389 );
390 log::debug!("[Falling back to text search...]");
391 }
392
393 let config = SearchConfig {
394 mode: SearchMode::Regex,
395 case_insensitive: false,
396 include_hidden: false,
397 follow_symlinks: false,
398 max_depth: None,
399 file_types: Vec::new(),
400 exclude_patterns: Vec::new(),
401 before_context: self.config.text_context_lines,
402 after_context: self.config.text_context_lines,
403 };
404
405 let searcher = self
406 .text_searcher()
407 .context("Text search unavailable during fallback")?;
408 let matches = searcher
409 .search(query, &[path], &config)
410 .context("Text search failed during fallback")?;
411
412 let matches = matches
413 .into_iter()
414 .take(self.config.max_text_results)
415 .collect::<Vec<_>>();
416
417 if self.config.show_search_mode {
418 log::debug!("[Text search: {} results]", matches.len());
419 }
420
421 Ok(SearchResults::Text {
422 matches,
423 mode: SearchModeUsed::SemanticFallbackToText,
424 })
425 }
426
427 Ok(results) => {
428 Ok(SearchResults::Semantic {
430 results,
431 mode: SearchModeUsed::SemanticOnly,
432 })
433 }
434
435 Err(e) if self.config.fallback_enabled => {
436 if self.config.show_search_mode {
438 log::debug!("[Semantic search failed: {e}]");
439 log::debug!("[Falling back to text search...]");
440 }
441
442 let config = SearchConfig {
443 mode: SearchMode::Regex,
444 case_insensitive: false,
445 include_hidden: false,
446 follow_symlinks: false,
447 max_depth: None,
448 file_types: Vec::new(),
449 exclude_patterns: Vec::new(),
450 before_context: self.config.text_context_lines,
451 after_context: self.config.text_context_lines,
452 };
453
454 let searcher = self
455 .text_searcher()
456 .context("Text search unavailable during fallback")?;
457 let matches = searcher
458 .search(query, &[path], &config)
459 .context("Text search failed during fallback")?;
460
461 let matches = matches
462 .into_iter()
463 .take(self.config.max_text_results)
464 .collect();
465
466 Ok(SearchResults::Text {
467 matches,
468 mode: SearchModeUsed::SemanticFallbackToText,
469 })
470 }
471
472 Err(e) => {
473 Err(e)
475 }
476 }
477 }
478}
479
480impl Default for FallbackSearchEngine {
481 fn default() -> Self {
482 let config = FallbackConfig::default();
483 let query_executor = QueryExecutor::new();
484 match TextSearcher::new() {
485 Ok(searcher) => Self::from_parts(query_executor, Some(searcher), None, config),
486 Err(err) => {
487 error!(
488 "FallbackSearchEngine default initialization failed; text search disabled: {err:#}"
489 );
490 Self::without_text_search(query_executor, config, &err)
491 }
492 }
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use std::fs;
500 use tempfile::TempDir;
501
502 fn setup_test_dir() -> TempDir {
503 let dir = TempDir::new().unwrap();
504 let test_file = dir.path().join("test.rs");
505 fs::write(
506 &test_file,
507 r#"
508pub fn foo() {
509 // TODO: add more functionality
510 println!("hello");
511}
512
513// bar intentionally simplified for fixture use
514fn bar() {
515 // Stubbed for test fixture coverage only
516}
517"#,
518 )
519 .unwrap();
520
521 dir
522 }
523
524 #[test]
525 fn test_hybrid_config_default() {
526 let config = FallbackConfig::default();
527 assert!(config.fallback_enabled);
528 assert_eq!(config.min_semantic_results, 1);
529 assert_eq!(config.text_context_lines, 2);
530 assert_eq!(config.max_text_results, 1000);
531 }
532
533 #[test]
534 fn test_hybrid_config_from_env() {
535 unsafe {
536 std::env::set_var("SQRY_FALLBACK_ENABLED", "false");
537 std::env::set_var("SQRY_MIN_SEMANTIC_RESULTS", "5");
538 std::env::set_var("SQRY_TEXT_CONTEXT_LINES", "3");
539 }
540
541 let config = FallbackConfig::from_env();
542 assert!(!config.fallback_enabled);
543 assert_eq!(config.min_semantic_results, 5);
544 assert_eq!(config.text_context_lines, 3);
545
546 unsafe {
548 std::env::remove_var("SQRY_FALLBACK_ENABLED");
549 std::env::remove_var("SQRY_MIN_SEMANTIC_RESULTS");
550 std::env::remove_var("SQRY_TEXT_CONTEXT_LINES");
551 }
552 }
553
554 #[test]
555 fn test_search_text_only() {
556 let dir = setup_test_dir();
557 let mut engine = FallbackSearchEngine::new().unwrap();
558
559 let results = engine.search_text_only("TODO", dir.path()).unwrap();
560
561 match results {
562 SearchResults::Text { matches, mode } => {
563 assert!(!matches.is_empty());
564 assert_eq!(mode, SearchModeUsed::TextOnly);
565 }
566 _ => panic!("Expected Text results"),
567 }
568 }
569
570 #[test]
571 fn test_search_results_len() {
572 let dir = setup_test_dir();
573 let mut engine = FallbackSearchEngine::new().unwrap();
574
575 let results = engine.search_text_only("TODO", dir.path()).unwrap();
576 assert!(!results.is_empty());
577 }
578
579 #[test]
580 fn test_hybrid_fallback_disabled() {
581 let dir = setup_test_dir();
582 let config = FallbackConfig {
583 fallback_enabled: false,
584 ..Default::default()
585 };
586
587 let mut engine = FallbackSearchEngine::with_config(config).unwrap();
588
589 let result = engine.search_hybrid("nonexistent", dir.path());
591
592 if let Ok(SearchResults::Semantic { results, .. }) = result {
594 assert_eq!(results.len(), 0);
595 }
596 }
597}