//! Interruptible, streaming search service. //! //! One dedicated worker thread owns the cascade. The GUI (or any caller) //! sends queries via [`SearchService::search`]; results stream back over //! an mpsc receiver as [`SearchUpdate`] events tagged with a generation //! number. Starting a new search bumps the generation and interrupts the //! in-flight SQLite statement, so a keystroke never waits on the previous //! query. //! //! Cancellation is two-layer: //! - **cooperative** — the cascade compares its generation against the //! latest every few hundred rows and stops silently when stale; //! - **interrupt** — [`rusqlite::InterruptHandle::interrupt`] kills the //! statement currently executing (covering the "no rows produced yet" //! phases like FTS candidate gathering). An interrupted stale search is //! normal cancellation, not an error. //! //! Consumers that want a plain blocking search (the CLI mode) skip the //! service entirely and call [`cascade::run`] with a collecting sink. pub mod cascade; pub mod duplicates; pub mod fuzzy; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread::JoinHandle; use crate::db; use crate::query::split::split_for_cascade; use crate::snippet::Snippet; pub use cascade::Outcome; pub use duplicates::{find_duplicate_groups, DuplicateGroup}; /// One search result. `rank` is the sort key (lower = better): integer /// part = cascade stage (1–11), fraction = occurrence-count or /// edit-distance tiebreak. Batches arrive already rank-ordered and later /// batches only append, so a rank-sorted view never reshuffles. #[derive(Debug, Clone)] pub struct SearchHit { pub file_id: i64, pub name: String, pub path: String, pub size: u64, pub mtime: i64, pub rank: f64, pub stage: u8, /// The matched span in context: the filename for name stages, the full /// path for path stages, a window of the body for full-text stages /// (absent there when document text isn't stored). pub snippet: Option, } #[derive(Debug, Clone)] pub enum SearchUpdate { Started { generation: u64 }, Hits { generation: u64, hits: Vec }, Completed { generation: u64, total: usize, limited: bool }, Error { generation: u64, message: String }, } impl SearchUpdate { pub fn generation(&self) -> u64 { match self { SearchUpdate::Started { generation } | SearchUpdate::Hits { generation, .. } | SearchUpdate::Completed { generation, .. } | SearchUpdate::Error { generation, .. } => *generation, } } } #[derive(Debug, Clone)] pub struct SearchOptions { /// Enable the fuzzy stages (ranks 7, 8 and 11). pub fuzzy: bool, /// Ceiling on the fuzzy edit budget (`[search].fuzzy_max_edits`); see /// [`fuzzy::edit_budget`]. 0 disables the fuzzy stages. pub fuzzy_max_edits: usize, /// Hard cap on total hits per search (`[search].display_limit`). pub limit: usize, /// Streaming batch size (`[search].results_per_page`). pub batch: usize, /// Session-scoped ignore patterns (GUI chips), same glob semantics as /// the config's `ignore_patterns`. Applied before the display cap. pub session_ignores: Vec, } impl Default for SearchOptions { fn default() -> Self { SearchOptions { fuzzy: false, fuzzy_max_edits: 2, limit: 1000, batch: 100, session_ignores: Vec::new(), } } } struct SearchRequest { generation: u64, input: String, options: SearchOptions, } pub struct SearchService { req_tx: mpsc::Sender, latest_gen: Arc, interrupt: Arc>>, db_path: Arc>, handle: Option>, } impl SearchService { /// Spawn the worker. `notify` is invoked after every update event so /// an egui frontend can `request_repaint` (pass a no-op for headless /// use). Returns the service handle plus the update receiver, which /// the caller drains non-blockingly. pub fn new( db_path: PathBuf, notify: Arc, ) -> (SearchService, mpsc::Receiver) { let (req_tx, req_rx) = mpsc::channel::(); let (update_tx, update_rx) = mpsc::channel::(); let latest_gen = Arc::new(AtomicU64::new(0)); let interrupt = Arc::new(Mutex::new(None)); let db_path = Arc::new(Mutex::new(db_path)); let worker = Worker { req_rx, update_tx, notify, latest_gen: latest_gen.clone(), interrupt: interrupt.clone(), db_path: db_path.clone(), }; let handle = std::thread::Builder::new() .name("qs-search".into()) .spawn(move || worker.run()) .expect("spawn search worker"); ( SearchService { req_tx, latest_gen, interrupt, db_path, handle: Some(handle), }, update_rx, ) } /// Start a new search, cancelling any in-flight one. Returns the /// generation whose events to keep. pub fn search(&self, input: &str, options: SearchOptions) -> u64 { let generation = self.latest_gen.fetch_add(1, Ordering::SeqCst) + 1; let _ = self.req_tx.send(SearchRequest { generation, input: input.to_string(), options, }); // The new request can't be running yet (the worker hasn't dequeued // it), so this only ever kills a stale generation's statement. self.interrupt_current(); generation } /// Cancel without starting anything new. pub fn cancel(&self) { self.latest_gen.fetch_add(1, Ordering::SeqCst); self.interrupt_current(); } /// Point subsequent searches at a different index file. pub fn set_db_path(&self, path: PathBuf) { *self.db_path.lock().unwrap() = path; self.cancel(); } fn interrupt_current(&self) { if let Ok(guard) = self.interrupt.lock() { if let Some(handle) = guard.as_ref() { handle.interrupt(); } } } /// Cancel, close the request channel, and join the worker. pub fn shutdown(self) { self.cancel(); let SearchService { req_tx, handle, .. } = self; drop(req_tx); if let Some(handle) = handle { let _ = handle.join(); } } } /// Map SQLite-level errors to the tagged strings frontends key off. /// `DATABASE_CORRUPTED:` drives the GUI's recovery dialog. pub fn classify_sql_err(error_msg: &str) -> String { if error_msg.starts_with(db::KEY_MISMATCH_PREFIX) { // Wrong or missing encryption key. Already user-legible, and it // must never fall into the corruption bucket — the recovery dialog // would offer to delete an index that is perfectly intact. error_msg.to_string() } else if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { format!("DATABASE_CORRUPTED: {}", error_msg) } else if error_msg.contains("fts5: syntax error") { "Search syntax error: the search term contains characters that cannot be processed." .to_string() } else { format!("Search failed: {}", error_msg) } } struct Worker { req_rx: mpsc::Receiver, update_tx: mpsc::Sender, notify: Arc, latest_gen: Arc, interrupt: Arc>>, db_path: Arc>, } impl Worker { fn run(self) { while let Ok(first) = self.req_rx.recv() { // A fast typist queues several requests; only the newest one // matters. let mut req = first; while let Ok(newer) = self.req_rx.try_recv() { req = newer; } if req.generation != self.latest_gen.load(Ordering::SeqCst) { continue; } self.handle(req); } } fn send(&self, update: SearchUpdate) { let _ = self.update_tx.send(update); (self.notify)(); } fn handle(&self, req: SearchRequest) { let generation = req.generation; self.send(SearchUpdate::Started { generation }); let split = match split_for_cascade(&req.input) { Ok(s) => s, Err(e) => { self.send(SearchUpdate::Error { generation, message: e.to_string(), }); return; } }; let db_path = self.db_path.lock().unwrap().clone(); // Per-request open: microseconds, and always sees a freshly // rebuilt index file rather than pinning a deleted inode. let conn = match db::open_existing(&db_path.to_string_lossy(), false) { Ok(c) => c, Err(e) => { self.send(SearchUpdate::Error { generation, message: classify_sql_err(&e), }); return; } }; *self.interrupt.lock().unwrap() = Some(conn.get_interrupt_handle()); let mut sink = |hits: Vec| { self.send(SearchUpdate::Hits { generation, hits }); }; let outcome = cascade::run( &conn, &split, &req.options, generation, &self.latest_gen, &mut sink, ); *self.interrupt.lock().unwrap() = None; match outcome { Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed { generation, total, limited, }), // Cancelled — the newer generation owns the UI now. Ok(None) => {} Err(e) => self.send(SearchUpdate::Error { generation, message: classify_sql_err(&e), }), } } } #[cfg(test)] mod tests { use super::*; #[test] fn key_mismatch_is_never_classified_as_corruption() { let msg = format!( "{}index at /tmp/x.sqlite: wrong password (or the file is not a QuickSearch index)", db::KEY_MISMATCH_PREFIX ); let classified = classify_sql_err(&msg); assert_eq!(classified, msg, "must pass through verbatim"); assert!(!classified.starts_with("DATABASE_CORRUPTED:")); // The raw SQLite wording for an undecryptable page must not land in // the corruption bucket either — the GUI recovery dialog offers to // delete the file, which is exactly wrong for an intact encrypted // index. (It doesn't match the corruption needles; pin that.) let raw = "Failed to read database at /tmp/x.sqlite: file is not a database"; assert!(!classify_sql_err(raw).starts_with("DATABASE_CORRUPTED:")); } #[test] fn corruption_and_syntax_classification_still_work() { assert!(classify_sql_err("database disk image is malformed") .starts_with("DATABASE_CORRUPTED:")); assert!(classify_sql_err("fts5: syntax error near \"NEAR\"").starts_with("Search syntax")); assert!(classify_sql_err("no such table: files").starts_with("Search failed:")); } }