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::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/// Configuration for fallback search behavior
20#[derive(Debug, Clone)]
21pub struct FallbackConfig {
22    /// Enable automatic fallback to text search (default: true)
23    pub fallback_enabled: bool,
24
25    /// Minimum semantic results before fallback (default: 1)
26    /// If semantic search returns fewer than this, fallback to text
27    pub min_semantic_results: usize,
28
29    /// Context lines for text search results (default: 2)
30    pub text_context_lines: usize,
31
32    /// Maximum text search results (default: 1000)
33    pub max_text_results: usize,
34
35    /// Show which search mode was used (default: true)
36    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    /// Load configuration from environment variables
53    ///
54    /// Supported environment variables:
55    /// - `SQRY_FALLBACK_ENABLED`: Enable/disable fallback (true/false)
56    /// - `SQRY_MIN_SEMANTIC_RESULTS`: Minimum semantic results threshold
57    /// - `SQRY_TEXT_CONTEXT_LINES`: Context lines for text results
58    /// - `SQRY_MAX_TEXT_RESULTS`: Maximum text results
59    /// - `SQRY_SHOW_SEARCH_MODE`: Show search mode (true/false)
60    #[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/// Search results from hybrid engine
89#[derive(Debug)]
90pub enum SearchResults {
91    /// Semantic (CodeGraph-based) results
92    Semantic {
93        /// Results from semantic search
94        results: QueryResults,
95        /// Which search mode was used
96        mode: SearchModeUsed,
97    },
98
99    /// Pure text (regex-based) results
100    Text {
101        /// Text matches found by ripgrep search
102        matches: Vec<TextMatch>,
103        /// Which search mode was used
104        mode: SearchModeUsed,
105    },
106}
107
108/// Which search mode was actually used
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SearchModeUsed {
111    /// Semantic search only
112    SemanticOnly,
113
114    /// Text search only
115    TextOnly,
116
117    /// Semantic succeeded (no fallback needed)
118    SemanticSucceeded,
119
120    /// Semantic returned empty, fell back to text
121    SemanticFallbackToText,
122
123    /// Both semantic and text (combined)
124    Combined,
125}
126
127impl SearchResults {
128    /// Get the total number of results
129    #[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    /// Check if results are empty
138    #[must_use]
139    pub fn is_empty(&self) -> bool {
140        self.len() == 0
141    }
142
143    /// Get the search mode that was used
144    #[must_use]
145    pub fn mode(&self) -> SearchModeUsed {
146        match self {
147            SearchResults::Semantic { mode, .. } | SearchResults::Text { mode, .. } => *mode,
148        }
149    }
150}
151
152/// Fallback search engine combining semantic and text search
153pub struct FallbackSearchEngine {
154    /// Semantic query executor (AST-based)
155    query_executor: QueryExecutor,
156
157    /// Text searcher (ripgrep-based)
158    text_searcher: Option<TextSearcher>,
159
160    /// Reason text searcher initialization failed (if unavailable)
161    text_search_error: Option<String>,
162
163    /// Configuration
164    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    /// Create a new fallback search engine with default configuration
201    ///
202    /// # Errors
203    ///
204    /// Returns [`anyhow::Error`] if the underlying [`FallbackSearchEngine::with_config`] call
205    /// fails to construct a text searcher.
206    pub fn new() -> Result<Self> {
207        Self::with_config(FallbackConfig::default())
208    }
209
210    /// Create a fallback search engine with custom configuration
211    ///
212    /// **Note:** This creates a `QueryExecutor` without plugins, so metadata field queries
213    /// like `async:true` and `visibility:public` will fail with "unknown field" errors.
214    /// For metadata field support, use [`with_config_and_executor`](Self::with_config_and_executor)
215    /// with a plugin-enabled executor.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`anyhow::Error`] if either the `QueryExecutor` or the text searcher cannot
220    /// be initialised.
221    pub fn with_config(config: FallbackConfig) -> Result<Self> {
222        Self::with_config_and_executor(config, QueryExecutor::new())
223    }
224
225    /// Create fallback search engine with custom query executor (with plugins)
226    ///
227    /// This constructor allows passing a `QueryExecutor` that has plugin fields registered,
228    /// enabling metadata queries like `async:true` and `visibility:public` in hybrid mode.
229    ///
230    /// # Arguments
231    /// * `config` - Hybrid search configuration
232    /// * `query_executor` - Pre-configured `QueryExecutor` with plugin fields
233    ///
234    /// # Example
235    /// ```no_run
236    /// use sqry_core::search::fallback::{FallbackSearchEngine, FallbackConfig};
237    /// use sqry_core::query::QueryExecutor;
238    /// use sqry_core::plugin::PluginManager;
239    ///
240    /// // Create plugin manager and register built-in plugins
241    /// let mut plugin_manager = PluginManager::new();
242    /// // Register plugins as needed (e.g., in CLI):
243    /// // plugin_manager.register_builtin(Box::new(sqry_lang_rust::RustPlugin::default()));
244    /// // plugin_manager.register_builtin(Box::new(sqry_lang_python::PythonPlugin::default()));
245    /// // ... register other plugins
246    ///
247    /// let query_executor = QueryExecutor::with_plugin_manager(plugin_manager);
248    /// let config = FallbackConfig::default();
249    /// let engine = FallbackSearchEngine::with_config_and_executor(config, query_executor);
250    /// ```
251    /// # Errors
252    ///
253    /// Returns [`anyhow::Error`] when the ripgrep-based text searcher fails to initialise.
254    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    /// Search with automatic mode detection and fallback
270    ///
271    /// # Arguments
272    /// * `query` - The search query
273    /// * `path` - The path to search in
274    ///
275    /// # Returns
276    /// `SearchResults` with the mode used and matched symbols/text
277    ///
278    /// # Example
279    /// ```no_run
280    /// use sqry_core::search::fallback::FallbackSearchEngine;
281    /// use std::path::Path;
282    ///
283    /// let mut engine = FallbackSearchEngine::new().unwrap();
284    /// // Search for functions in current directory
285    /// let results = engine.search("kind:function", Path::new("."));
286    /// ```
287    /// # Errors
288    ///
289    /// Returns [`anyhow::Error`] if either semantic or text search fails and no fallback
290    /// mode can recover (for example, when both engines encounter I/O errors).
291    pub fn search(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
292        // Step 1: Classify query
293        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        // Step 2: Execute based on classification
304        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    /// Force semantic search only (no fallback)
312    ///
313    /// # Errors
314    ///
315    /// Returns [`anyhow::Error`] when the semantic query executor fails to parse or execute
316    /// the request (invalid syntax, missing graph, or predicate errors).
317    pub fn search_semantic_only(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
318        // Execute query using CodeGraph
319        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    /// SGA03 Major #1 — semantic-only search against a caller-supplied
328    /// [`CodeGraph`].
329    ///
330    /// Identical contract to [`Self::search_semantic_only`] but routes the
331    /// semantic execution through
332    /// [`QueryExecutor::execute_on_preloaded_graph`] instead of
333    /// [`QueryExecutor::execute_on_graph`]. This is the entrypoint the CLI
334    /// hybrid path uses after the shared `FilesystemGraphProvider` has
335    /// already acquired the workspace graph: re-loading from disk inside
336    /// the executor would bypass the provider's plugin/manifest checks.
337    ///
338    /// `scope_path` is the **search scope** (a directory or file under the
339    /// workspace) — not the workspace root. The executor canonicalises it
340    /// internally before evaluating file-scope predicates, mirroring
341    /// [`QueryExecutor::execute_on_graph_with_variables`].
342    ///
343    /// # Errors
344    ///
345    /// Returns [`anyhow::Error`] when query parsing, variable resolution, or
346    /// predicate evaluation fails. Cannot produce a "no graph found" error —
347    /// the graph is always supplied by the caller.
348    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    /// Force text search only (no semantic attempt)
365    ///
366    /// # Errors
367    ///
368    /// Returns [`anyhow::Error`] when text search is disabled/unavailable or when ripgrep
369    /// returns an error while scanning the requested paths.
370    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        // Limit results
392        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    /// SGA03 Major #1 — hybrid auto-classified search against a
404    /// caller-supplied [`CodeGraph`].
405    ///
406    /// Mirrors [`Self::search`] but threads the provider-acquired graph
407    /// into every semantic execution. The text-only branch does not need
408    /// the graph and matches [`Self::search_text_only`] verbatim.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`anyhow::Error`] if either semantic or text search fails
413    /// and no fallback mode can recover.
414    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    /// Hybrid search: try semantic first, fallback to text if needed
440    fn search_hybrid(&mut self, query: &str, path: &Path) -> Result<SearchResults> {
441        // Try semantic search first using CodeGraph
442        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                // Semantic search succeeded with sufficient results
447                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                // Semantic returned too few results - fallback to text
459                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                // Fallback disabled, return semantic results as-is
504                Ok(SearchResults::Semantic {
505                    results,
506                    mode: SearchModeUsed::SemanticOnly,
507                })
508            }
509
510            Err(e) if self.config.fallback_enabled => {
511                // Semantic search failed - fallback to text
512                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                // Fallback disabled and semantic failed - return error
549                Err(e)
550            }
551        }
552    }
553
554    /// SGA03 Major #1 — hybrid search against a caller-supplied
555    /// [`CodeGraph`].
556    ///
557    /// Functionally identical to [`Self::search_hybrid`] except the
558    /// semantic attempt runs through
559    /// [`QueryExecutor::execute_on_preloaded_graph`] so the
560    /// provider-acquired graph is the single source of truth — the
561    /// executor's process-wide cache is **not** consulted and
562    /// [`crate::graph::unified::persistence::load_from_path`] is **not**
563    /// re-entered. The text-fallback branches reuse the existing
564    /// [`super::Searcher`] verbatim.
565    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    /// Shared text-fallback path used by hybrid variants. Mirrors the
619    /// inline blocks in [`Self::search_hybrid`] so the preloaded-graph
620    /// hybrid path produces identical text fallbacks.
621    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        // Cleanup
726        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        // This should fail with fallback disabled (no semantic match for "TODO")
769        let result = engine.search_hybrid("nonexistent", dir.path());
770
771        // Should return empty semantic results (not fallback to text)
772        if let Ok(SearchResults::Semantic { results, .. }) = result {
773            assert_eq!(results.len(), 0);
774        }
775    }
776
777    /// SGA03 Major #1 (codex iter2) — preloaded-graph entrypoints route
778    /// through `QueryExecutor::execute_on_preloaded_graph`, never through
779    /// the executor's `execute_on_graph` cache+disk-load path.
780    ///
781    /// The proof here is *negative*: pass an empty in-memory `CodeGraph`
782    /// against a `path` whose ancestors contain no `.sqry/graph` artifact.
783    /// If the engine were still using `execute_on_graph`, the executor's
784    /// `get_or_load_graph` would fail with "No graph found. Run `sqry
785    /// index ...`". Because the entrypoint takes the caller's graph
786    /// directly, the call must succeed and return zero semantic matches.
787    #[test]
788    fn semantic_only_with_preloaded_graph_uses_caller_graph() {
789        let dir = TempDir::new().unwrap();
790        // Deliberately *no* `.sqry/graph/...` artifact under `dir`.
791
792        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    /// Companion to the above for the hybrid auto-classify entrypoint:
811    /// against an empty preloaded graph + no on-disk artifact, the
812    /// semantic attempt must come back with zero results and (because
813    /// `kind:function` is a Semantic-classified query) the engine must
814    /// not silently downgrade to text fallback.
815    #[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}