Skip to main content

sqry_core/search/
fallback.rs

1//! Fallback search engine combining semantic (AST) and text (ripgrep) search
2//!
3//! This module implements intelligent query execution that automatically falls back
4//! to text search when semantic search returns insufficient results.
5//!
6//! Note: This is distinct from the embedding-based hybrid search,
7//! which combines vectors + AST/graph. This module handles AST → ripgrep fallback.
8
9use 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/// Configuration for fallback search behavior
18#[derive(Debug, Clone)]
19pub struct FallbackConfig {
20    /// Enable automatic fallback to text search (default: true)
21    pub fallback_enabled: bool,
22
23    /// Minimum semantic results before fallback (default: 1)
24    /// If semantic search returns fewer than this, fallback to text
25    pub min_semantic_results: usize,
26
27    /// Context lines for text search results (default: 2)
28    pub text_context_lines: usize,
29
30    /// Maximum text search results (default: 1000)
31    pub max_text_results: usize,
32
33    /// Show which search mode was used (default: true)
34    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    /// Load configuration from environment variables
51    ///
52    /// Supported environment variables:
53    /// - `SQRY_FALLBACK_ENABLED`: Enable/disable fallback (true/false)
54    /// - `SQRY_MIN_SEMANTIC_RESULTS`: Minimum semantic results threshold
55    /// - `SQRY_TEXT_CONTEXT_LINES`: Context lines for text results
56    /// - `SQRY_MAX_TEXT_RESULTS`: Maximum text results
57    /// - `SQRY_SHOW_SEARCH_MODE`: Show search mode (true/false)
58    #[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/// Search results from hybrid engine
87#[derive(Debug)]
88pub enum SearchResults {
89    /// Semantic (CodeGraph-based) results
90    Semantic {
91        /// Results from semantic search
92        results: QueryResults,
93        /// Which search mode was used
94        mode: SearchModeUsed,
95    },
96
97    /// Pure text (regex-based) results
98    Text {
99        /// Text matches found by ripgrep search
100        matches: Vec<TextMatch>,
101        /// Which search mode was used
102        mode: SearchModeUsed,
103    },
104}
105
106/// Which search mode was actually used
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum SearchModeUsed {
109    /// Semantic search only
110    SemanticOnly,
111
112    /// Text search only
113    TextOnly,
114
115    /// Semantic succeeded (no fallback needed)
116    SemanticSucceeded,
117
118    /// Semantic returned empty, fell back to text
119    SemanticFallbackToText,
120
121    /// Both semantic and text (combined)
122    Combined,
123}
124
125impl SearchResults {
126    /// Get the total number of results
127    #[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    /// Check if results are empty
136    #[must_use]
137    pub fn is_empty(&self) -> bool {
138        self.len() == 0
139    }
140
141    /// Get the search mode that was used
142    #[must_use]
143    pub fn mode(&self) -> SearchModeUsed {
144        match self {
145            SearchResults::Semantic { mode, .. } | SearchResults::Text { mode, .. } => *mode,
146        }
147    }
148}
149
150/// Fallback search engine combining semantic and text search
151pub struct FallbackSearchEngine {
152    /// Semantic query executor (AST-based)
153    query_executor: QueryExecutor,
154
155    /// Text searcher (ripgrep-based)
156    text_searcher: Option<TextSearcher>,
157
158    /// Reason text searcher initialization failed (if unavailable)
159    text_search_error: Option<String>,
160
161    /// Configuration
162    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    /// Create a new fallback search engine with default configuration
199    ///
200    /// # Errors
201    ///
202    /// Returns [`anyhow::Error`] if the underlying [`FallbackSearchEngine::with_config`] call
203    /// fails to construct a text searcher.
204    pub fn new() -> Result<Self> {
205        Self::with_config(FallbackConfig::default())
206    }
207
208    /// Create a fallback search engine with custom configuration
209    ///
210    /// **Note:** This creates a `QueryExecutor` without plugins, so metadata field queries
211    /// like `async:true` and `visibility:public` will fail with "unknown field" errors.
212    /// For metadata field support, use [`with_config_and_executor`](Self::with_config_and_executor)
213    /// with a plugin-enabled executor.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`anyhow::Error`] if either the `QueryExecutor` or the text searcher cannot
218    /// be initialised.
219    pub fn with_config(config: FallbackConfig) -> Result<Self> {
220        Self::with_config_and_executor(config, QueryExecutor::new())
221    }
222
223    /// Create fallback search engine with custom query executor (with plugins)
224    ///
225    /// This constructor allows passing a `QueryExecutor` that has plugin fields registered,
226    /// enabling metadata queries like `async:true` and `visibility:public` in hybrid mode.
227    ///
228    /// # Arguments
229    /// * `config` - Hybrid search configuration
230    /// * `query_executor` - Pre-configured `QueryExecutor` with plugin fields
231    ///
232    /// # Example
233    /// ```no_run
234    /// use sqry_core::search::fallback::{FallbackSearchEngine, FallbackConfig};
235    /// use sqry_core::query::QueryExecutor;
236    /// use sqry_core::plugin::PluginManager;
237    ///
238    /// // Create plugin manager and register built-in plugins
239    /// let mut plugin_manager = PluginManager::new();
240    /// // Register plugins as needed (e.g., in CLI):
241    /// // plugin_manager.register_builtin(Box::new(sqry_lang_rust::RustPlugin::default()));
242    /// // plugin_manager.register_builtin(Box::new(sqry_lang_python::PythonPlugin::default()));
243    /// // ... register other plugins
244    ///
245    /// let query_executor = QueryExecutor::with_plugin_manager(plugin_manager);
246    /// let config = FallbackConfig::default();
247    /// let engine = FallbackSearchEngine::with_config_and_executor(config, query_executor);
248    /// ```
249    /// # Errors
250    ///
251    /// Returns [`anyhow::Error`] when the ripgrep-based text searcher fails to initialise.
252    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    /// Search with automatic mode detection and fallback
268    ///
269    /// # Arguments
270    /// * `query` - The search query
271    /// * `path` - The path to search in
272    ///
273    /// # Returns
274    /// `SearchResults` with the mode used and matched symbols/text
275    ///
276    /// # Example
277    /// ```no_run
278    /// use sqry_core::search::fallback::FallbackSearchEngine;
279    /// use std::path::Path;
280    ///
281    /// let mut engine = FallbackSearchEngine::new().unwrap();
282    /// // Search for functions in current directory
283    /// let results = engine.search("kind:function", Path::new("."));
284    /// ```
285    /// # Errors
286    ///
287    /// Returns [`anyhow::Error`] if either semantic or text search fails and no fallback
288    /// mode can recover (for example, when both engines encounter I/O errors).
289    pub fn search(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
290        // Step 1: Classify query
291        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        // Step 2: Execute based on classification
302        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    /// Force semantic search only (no fallback)
310    ///
311    /// # Errors
312    ///
313    /// Returns [`anyhow::Error`] when the semantic query executor fails to parse or execute
314    /// the request (invalid syntax, missing graph, or predicate errors).
315    pub fn search_semantic_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
316        // Execute query using CodeGraph
317        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    /// Force text search only (no semantic attempt)
326    ///
327    /// # Errors
328    ///
329    /// Returns [`anyhow::Error`] when text search is disabled/unavailable or when ripgrep
330    /// returns an error while scanning the requested paths.
331    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        // Limit results
353        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    /// Hybrid search: try semantic first, fallback to text if needed
365    fn search_hybrid(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
366        // Try semantic search first using CodeGraph
367        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                // Semantic search succeeded with sufficient results
372                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                // Semantic returned too few results - fallback to text
384                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                // Fallback disabled, return semantic results as-is
429                Ok(SearchResults::Semantic {
430                    results,
431                    mode: SearchModeUsed::SemanticOnly,
432                })
433            }
434
435            Err(e) if self.config.fallback_enabled => {
436                // Semantic search failed - fallback to text
437                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                // Fallback disabled and semantic failed - return error
474                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        // Cleanup
547        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        // This should fail with fallback disabled (no semantic match for "TODO")
590        let result = engine.search_hybrid("nonexistent", dir.path());
591
592        // Should return empty semantic results (not fallback to text)
593        if let Ok(SearchResults::Semantic { results, .. }) = result {
594            assert_eq!(results.len(), 0);
595        }
596    }
597}