Skip to main content

wm_memory/
search.rs

1//! Search engine — Tantivy full-text search.
2//!
3//! Provides BM25-scored full-text search over memory content.
4//! Index is stored alongside the LMDB store in a separate directory.
5//!
6//! Recall-quality hygiene (see `docs/TANTIVY_RECALL_QUALITY_FIX.md`):
7//! - Queries are stripped of common stopwords before parsing.
8//! - Terms are only quoted when they contain reserved query syntax
9//!   (plain terms — including hyphenated compounds — pass through so the
10//!   tokenizer can split them into phrase matches).
11//! - Content is sanitized at index time (binary/garbage content is skipped).
12//! - Results are filtered by optional absolute and/or relative score floors,
13//!   and output content is scrubbed of control characters.
14
15use crate::MemoryId;
16use serde::{Deserialize, Serialize};
17use wm_core::{CoreError, Galaxy, Result};
18
19use std::path::Path;
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicU64, Ordering};
22use tantivy::{
23    Index, IndexReader, IndexWriter, ReloadPolicy, Term,
24    collector::TopDocs,
25    doc,
26    query::QueryParser,
27    schema::{
28        Field, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, TextOptions, Value,
29    },
30};
31
32/// Maximum content length (in chars) indexed into Tantivy.
33pub const MAX_INDEX_CONTENT_LEN: usize = 8 * 1024;
34
35/// Minimum printable-char ratio for content to be indexed (0.9 = max 10% garbage).
36pub const MIN_PRINTABLE_RATIO: f32 = 0.9;
37
38/// Common English stopwords stripped from queries before parsing.
39///
40/// Mirrors the client-side stopword list (Antigravity `wmMemory.ts`) so the
41/// server and client agree on which tokens are meaningless for recall.
42pub const STOPWORDS: &[&str] = &[
43    "a",
44    "about",
45    "after",
46    "again",
47    "all",
48    "also",
49    "am",
50    "an",
51    "and",
52    "any",
53    "are",
54    "as",
55    "at",
56    "be",
57    "been",
58    "being",
59    "before",
60    "between",
61    "both",
62    "but",
63    "by",
64    "can",
65    "could",
66    "did",
67    "do",
68    "does",
69    "during",
70    "each",
71    "few",
72    "for",
73    "from",
74    "further",
75    "had",
76    "has",
77    "have",
78    "he",
79    "her",
80    "here",
81    "hers",
82    "herself",
83    "him",
84    "himself",
85    "his",
86    "how",
87    "i",
88    "if",
89    "in",
90    "into",
91    "is",
92    "it",
93    "its",
94    "itself",
95    "just",
96    "me",
97    "might",
98    "more",
99    "most",
100    "my",
101    "myself",
102    "no",
103    "nor",
104    "not",
105    "of",
106    "off",
107    "on",
108    "once",
109    "only",
110    "or",
111    "other",
112    "our",
113    "ours",
114    "ourselves",
115    "out",
116    "over",
117    "own",
118    "same",
119    "shall",
120    "she",
121    "should",
122    "so",
123    "some",
124    "such",
125    "than",
126    "that",
127    "the",
128    "their",
129    "theirs",
130    "them",
131    "themselves",
132    "then",
133    "there",
134    "these",
135    "they",
136    "this",
137    "those",
138    "through",
139    "to",
140    "too",
141    "under",
142    "until",
143    "up",
144    "us",
145    "very",
146    "was",
147    "we",
148    "were",
149    "what",
150    "when",
151    "where",
152    "which",
153    "while",
154    "who",
155    "whom",
156    "why",
157    "will",
158    "with",
159    "would",
160    "you",
161    "your",
162    "yours",
163    "yourself",
164    "yourselves",
165];
166
167/// Search options controlling recall behavior.
168#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
169pub struct SearchOptions {
170    /// Maximum number of results.
171    pub limit: usize,
172    /// Optional galaxy filter (matches the stored galaxy string).
173    pub galaxy: Option<Galaxy>,
174    /// Absolute BM25 score floor; hits scoring below are dropped.
175    pub min_score: Option<f32>,
176    /// Relative floor: hits scoring below `top_score * ratio` are dropped
177    /// (e.g. `0.05` keeps only hits within 5% of the top result).
178    pub relative_floor: Option<f32>,
179    /// Use OR semantics instead of conjunction (deprecated — OR is now the
180    /// default; this flag is kept for API compatibility but does not change
181    /// behavior).
182    pub relaxed: bool,
183}
184
185impl Default for SearchOptions {
186    fn default() -> Self {
187        Self {
188            limit: 20,
189            galaxy: None,
190            min_score: None,
191            relative_floor: None,
192            relaxed: false,
193        }
194    }
195}
196
197/// Search result item.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SearchResult {
200    /// Memory UUID (as string)
201    pub memory_id: String,
202    /// Galaxy name
203    pub galaxy: String,
204    /// Raw BM25 score
205    pub score: f32,
206    /// Score relative to the top hit (1.0 = top result, 0.0 = no results)
207    pub normalized_score: f32,
208    /// Content snippet (control characters scrubbed)
209    pub content: String,
210}
211
212/// Tracked health of the Tantivy index relative to LMDB.
213///
214/// Because Tantivy indexing is best-effort (an indexing failure does not
215/// roll back the LMDB write), the index can drift from the store. This
216/// struct tracks successes and failures so `wm doctor` and `system.health`
217/// can report degraded state instead of silently claiming healthy.
218#[derive(Debug, Default)]
219pub struct IndexHealth {
220    /// Successful index/deindex operations since startup.
221    pub successes: AtomicU64,
222    /// Failed index/deindex operations since startup.
223    pub failures: AtomicU64,
224    /// Last error message (empty string if none).
225    last_error: Mutex<String>,
226}
227
228impl IndexHealth {
229    fn record_success(&self) {
230        self.successes.fetch_add(1, Ordering::Relaxed);
231    }
232
233    fn record_failure(&self, err: &str) {
234        self.failures.fetch_add(1, Ordering::Relaxed);
235        if let Ok(mut guard) = self.last_error.lock() {
236            *guard = err.to_string();
237        }
238    }
239
240    /// Snapshot the health as a JSON value for tool output.
241    #[must_use]
242    pub fn snapshot(&self) -> serde_json::Value {
243        let successes = self.successes.load(Ordering::Relaxed);
244        let failures = self.failures.load(Ordering::Relaxed);
245        let last_error = self
246            .last_error
247            .lock()
248            .map(|g| g.clone())
249            .unwrap_or_default();
250        let degraded = failures > 0;
251        serde_json::json!({
252            "successes": successes,
253            "failures": failures,
254            "degraded": degraded,
255            "last_error": if last_error.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(last_error) },
256        })
257    }
258}
259
260/// Format the Tantivy writer-creation error with actionable lock context
261/// (backlog B1: the bare `Lockfile: LockBusy` message named neither the
262/// index path nor the likely holder, costing a debug session to isolate).
263///
264/// The lock case does not echo the raw error: Tantivy formats its lock
265/// payload as a Debug-wrapped `Some("…")`, which reads like an internal
266/// trace rather than an expected contention message (2026-09-15 audit).
267fn format_writer_lock_error(err: &str, index_path: &Path) -> String {
268    let is_lock = err.contains("ock") && (err.contains("Busy") || err.contains("lock"));
269    if is_lock {
270        format!(
271            "search index lock busy — the index at {} is locked by another process. \
272             A running `wm serve` or `wm daemon` on this store holds it; find it with \
273             `pgrep -af wm` and stop it, or start this server with --readonly.",
274            index_path.display()
275        )
276    } else {
277        format!("Tantivy writer: {err}")
278    }
279}
280
281/// The full-text search engine backed by Tantivy.
282pub struct SearchEngine {
283    index: Index,
284    reader: IndexReader,
285    writer: Mutex<Option<IndexWriter>>,
286    field_id: Field,
287    field_galaxy: Field,
288    field_content: Field,
289    field_tags: Field,
290    field_timestamp: Field,
291    /// Tracked index health — failures are recorded so callers can detect
292    /// degraded state instead of silently reporting healthy.
293    health: IndexHealth,
294    /// True when the on-disk index had an incompatible schema and was
295    /// replaced with a fresh empty index at open time. The old index was
296    /// moved aside (`.schema-mismatch.<timestamp>` sibling); callers that
297    /// own the canonical LMDB store should rebuild via
298    /// [`crate::reindex::rebuild_index`].
299    schema_migrated: bool,
300}
301
302impl SearchEngine {
303    /// Build the Tantivy schema for memory indexing.
304    fn build_schema() -> (Schema, Field, Field, Field, Field, Field) {
305        let mut schema_builder = Schema::builder();
306        let field_id = schema_builder.add_text_field("memory_id", STRING | STORED);
307        let field_galaxy = schema_builder.add_text_field("galaxy", STRING | STORED);
308        // Use en_stem tokenizer for content and tags so that morphological
309        // variants match (e.g. "graduate" ↔ "graduated", "degree" ↔ "degrees").
310        let stem_indexing = TextFieldIndexing::default()
311            .set_tokenizer("en_stem")
312            .set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions);
313        let stem_text = TextOptions::default()
314            .set_indexing_options(stem_indexing.clone())
315            .set_stored();
316        let stem_tags = TextOptions::default().set_indexing_options(stem_indexing);
317        let field_content = schema_builder.add_text_field("content", stem_text);
318        let field_tags = schema_builder.add_text_field("tags", stem_tags);
319        let field_timestamp = schema_builder.add_i64_field("timestamp", STORED);
320        let schema = schema_builder.build();
321        (
322            schema,
323            field_id,
324            field_galaxy,
325            field_content,
326            field_tags,
327            field_timestamp,
328        )
329    }
330
331    /// Open (or create) the Tantivy index at `path`, migrating an
332    /// incompatible schema when `writable`.
333    ///
334    /// The Tantivy index is a derived index over the canonical LMDB store.
335    /// When the on-disk index was written by an older version with an
336    /// incompatible schema, a writable open moves the old directory aside
337    /// (`<name>.schema-mismatch.<millis>` sibling) and creates a fresh empty
338    /// index — the caller should rebuild it from LMDB via
339    /// [`crate::reindex::rebuild_index`]. A read-only open refuses to migrate
340    /// and returns an error directing the user to `wm reindex`.
341    fn open_index(path: &Path, schema: &Schema, writable: bool) -> Result<(Index, bool)> {
342        let directory = tantivy::directory::MmapDirectory::open(path)
343            .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
344        if !writable {
345            // `open_or_create` initializes an index in an existing but empty
346            // directory. A preservation open must only accept an already
347            // materialized index, never create its metadata or segments.
348            let index = Index::open(directory).map_err(|e| {
349                CoreError::Memory(format!(
350                    "Tantivy readonly open-existing at {}: {e}",
351                    path.display()
352                ))
353            })?;
354            if index.schema() != *schema {
355                return Err(CoreError::Memory(format!(
356                    "Tantivy index at {} was created with an incompatible schema by an \
357                     older version. Run 'wm reindex' (or start 'wm serve' without \
358                     --readonly) to migrate and rebuild it from the canonical store.",
359                    path.display()
360                )));
361            }
362            return Ok((index, false));
363        }
364        match Index::open_or_create(directory, schema.clone()) {
365            Ok(index) => Ok((index, false)),
366            Err(tantivy::error::TantivyError::SchemaError(_)) => {
367                let ts = std::time::SystemTime::now()
368                    .duration_since(std::time::UNIX_EPOCH)
369                    .map_or(0, |d| d.as_millis());
370                let file_name = path
371                    .file_name()
372                    .and_then(|n| n.to_str())
373                    .unwrap_or("tantivy");
374                let backup = path.with_file_name(format!("{file_name}.schema-mismatch.{ts}"));
375                std::fs::rename(path, &backup).map_err(|e| {
376                    CoreError::Memory(format!(
377                        "Tantivy schema migration — rename old index to {}: {e}",
378                        backup.display()
379                    ))
380                })?;
381                std::fs::create_dir_all(path).map_err(|e| {
382                    CoreError::Memory(format!("Tantivy schema migration — create index dir: {e}"))
383                })?;
384                tracing::warn!(
385                    "Tantivy index schema mismatch — old index moved to {}; creating a fresh \
386                     index (rebuild from LMDB will follow)",
387                    backup.display()
388                );
389                let directory = tantivy::directory::MmapDirectory::open(path)
390                    .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
391                let index = Index::open_or_create(directory, schema.clone())
392                    .map_err(|e| CoreError::Memory(format!("Tantivy open_or_create: {e}")))?;
393                Ok((index, true))
394            }
395            Err(e) => Err(CoreError::Memory(format!("Tantivy open_or_create: {e}"))),
396        }
397    }
398
399    /// Create or open a search engine index at the given path.
400    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
401        let path = path.as_ref();
402        let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
403            Self::build_schema();
404
405        let (index, schema_migrated) = Self::open_index(path, &schema, true)?;
406
407        let reader = index
408            .reader_builder()
409            .reload_policy(ReloadPolicy::OnCommitWithDelay)
410            .try_into()
411            .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
412
413        let writer = index
414            .writer(50_000_000)
415            .map_err(|e| CoreError::Memory(format_writer_lock_error(&e.to_string(), path)))?;
416
417        Ok(Self {
418            index,
419            reader,
420            writer: Mutex::new(Some(writer)),
421            field_id,
422            field_galaxy,
423            field_content,
424            field_tags,
425            field_timestamp,
426            health: IndexHealth::default(),
427            schema_migrated,
428        })
429    }
430
431    /// Open the index in read-only mode: no writer is created, so no
432    /// exclusive tantivy lock is taken. Multiple processes (e.g. Antigravity's
433    /// proxy and an opencode MCP client) can share the store for searches;
434    /// writes through this engine fail with a clear error.
435    pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
436        Self::open_readonly_with_disclosure(path, true)
437    }
438
439    /// [`open_readonly`] without the open-time write-visibility warning:
440    /// for one-shot inspection processes (`wm doctor`) that exit before any
441    /// later write could matter. The read-only server keeps the loud path —
442    /// there the warning is the honest Backlog B2 disclosure.
443    pub fn open_readonly_quiet(path: impl AsRef<Path>) -> Result<Self> {
444        Self::open_readonly_with_disclosure(path, false)
445    }
446
447    fn open_readonly_with_disclosure(path: impl AsRef<Path>, loud: bool) -> Result<Self> {
448        let path = path.as_ref();
449        // Backlog B2: a read-only open never observes later writes — searches
450        // miss fresh memories until restart. Say so at the moment it matters
451        // (a long-lived reader); a one-shot CLI process exits first.
452        if loud {
453            tracing::warn!(
454                "read-only search index opened at {} — it will not observe writes made \
455                 after this point; restart the read-only server to pick up new memories",
456                path.display()
457            );
458        } else {
459            tracing::debug!(
460                "read-only search index opened at {} (inspection process)",
461                path.display()
462            );
463        }
464        let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
465            Self::build_schema();
466        let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
467        let reader = index
468            .reader_builder()
469            .reload_policy(ReloadPolicy::OnCommitWithDelay)
470            .try_into()
471            .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
472        Ok(Self {
473            index,
474            reader,
475            writer: Mutex::new(None),
476            field_id,
477            field_galaxy,
478            field_content,
479            field_tags,
480            field_timestamp,
481            health: IndexHealth::default(),
482            schema_migrated,
483        })
484    }
485
486    /// True when the on-disk index had an incompatible schema and was
487    /// replaced with a fresh empty index at open time. The old index was
488    /// preserved as a `.schema-mismatch.<timestamp>` sibling directory.
489    /// Callers that own the canonical LMDB store should rebuild via
490    /// `reindex::rebuild_index` when this returns true.
491    #[must_use]
492    pub const fn schema_migrated(&self) -> bool {
493        self.schema_migrated
494    }
495
496    /// Returns a snapshot of index health (success/failure counts, degraded
497    /// flag, last error).
498    #[must_use]
499    pub const fn health(&self) -> &IndexHealth {
500        &self.health
501    }
502
503    /// Count the number of indexed documents for a specific galaxy.
504    ///
505    /// Used by consistency checks to compare Tantivy doc counts against
506    /// LMDB memory counts. Returns 0 if the index is empty or the galaxy
507    /// has no documents.
508    pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
509        // Deterministic view: the OnCommitWithDelay background reloader can
510        // lag a just-finished commit; drift classification must never run
511        // against a stale reader (it would under-count and skip the heal).
512        self.reader
513            .reload()
514            .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
515        let searcher = self.reader.searcher();
516        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
517        let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
518        let count = searcher
519            .search(&query, &tantivy::collector::Count)
520            .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
521        Ok(count)
522    }
523
524    /// Enumerate the memory IDs currently indexed for one galaxy.
525    ///
526    /// Used by the incremental drift heal to diff the index against LMDB
527    /// without rebuilding whole galaxies. Bounded by the galaxy's own
528    /// document count (a term query on the non-tokenized `galaxy` field, so
529    /// the cost is one term seek + one stored-field fetch per hit).
530    pub fn indexed_ids_in_galaxy(&self, galaxy: &str) -> Result<std::collections::HashSet<String>> {
531        // Deterministic view: the OnCommitWithDelay background reloader can
532        // lag a just-finished commit, and the drift heal must never diff
533        // against a stale reader (it would re-index what it already did).
534        self.reader
535            .reload()
536            .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
537        let searcher = self.reader.searcher();
538        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
539        let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
540        let count = self.count_docs_in_galaxy(galaxy)?;
541        let hits: std::collections::HashSet<tantivy::DocAddress> = searcher
542            .search(&query, &tantivy::collector::DocSetCollector)
543            .map_err(|e| CoreError::Memory(format!("Tantivy indexed_ids: {e}")))?;
544        let mut out = std::collections::HashSet::with_capacity(count);
545        for addr in hits {
546            let doc: TantivyDocument = searcher
547                .doc(addr)
548                .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
549            if let Some(id) = doc.get_first(self.field_id).and_then(|v| v.as_str()) {
550                out.insert(id.to_string());
551            }
552        }
553        Ok(out)
554    }
555
556    /// True when the engine was opened read-only (no tantivy writer).
557    pub fn is_readonly(&self) -> bool {
558        self.writer.lock().map_or(true, |g| g.is_none())
559    }
560
561    /// Lock the shared writer for adding/removing documents.
562    ///
563    /// The writer is created at `open()` time and shared across all callers
564    /// via a `Mutex`, preventing lock contention with Tantivy's single-writer model.
565    /// In read-only mode this errors.
566    pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
567        let guard = self
568            .writer
569            .lock()
570            .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
571        if guard.is_none() {
572            return Err(CoreError::Memory(
573                "Tantivy writer unavailable: index opened read-only".into(),
574            ));
575        }
576        Ok(guard)
577    }
578
579    /// Index a memory document.
580    ///
581    /// Content that is not clean text (binary garbage, low printable-char
582    /// ratio, null bytes) is **skipped** at index time — no document is added
583    /// and `Ok(())` is returned so callers can proceed. See
584    /// [`sanitize_content_for_index`].
585    pub fn add_document(
586        &self,
587        writer: &mut Option<IndexWriter>,
588        memory_id: &str,
589        galaxy: &str,
590        content: &str,
591        tags: &[String],
592        timestamp: i64,
593    ) -> Result<()> {
594        let writer = writer.as_mut().ok_or_else(|| {
595            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
596        })?;
597        let Some(clean_content) = sanitize_content_for_index(content) else {
598            tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
599            return Ok(());
600        };
601        let tags_str = tags.join(" ");
602        let doc = doc!(
603            self.field_id => memory_id,
604            self.field_galaxy => galaxy,
605            self.field_content => clean_content,
606            self.field_tags => tags_str,
607            self.field_timestamp => timestamp,
608        );
609        match writer.add_document(doc) {
610            Ok(_) => {
611                self.health.record_success();
612                Ok(())
613            }
614            Err(e) => {
615                let msg = format!("Tantivy add_document: {e}");
616                self.health.record_failure(&msg);
617                Err(CoreError::Memory(msg))
618            }
619        }
620    }
621
622    /// Index a complete `Memory` entry.
623    pub fn index_memory(
624        &self,
625        writer: &mut Option<IndexWriter>,
626        mem: &crate::memory::Memory,
627    ) -> Result<()> {
628        self.add_document(
629            writer,
630            &mem.metadata.id.to_string(),
631            mem.metadata.galaxy.db_name(),
632            &mem.content,
633            &mem.metadata.tags,
634            mem.metadata.created_at.timestamp(),
635        )
636    }
637
638    /// Delete documents by memory ID.
639    pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
640        let writer = writer.as_mut().ok_or_else(|| {
641            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
642        })?;
643        let term = tantivy::Term::from_field_text(self.field_id, memory_id);
644        writer.delete_term(term);
645        Ok(())
646    }
647
648    /// Delete every document belonging to a galaxy.
649    ///
650    /// Used by filtered reindexing so `--galaxy codex` removes only codex
651    /// documents instead of wiping the entire index.
652    pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
653        let writer = writer.as_mut().ok_or_else(|| {
654            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
655        })?;
656        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
657        writer.delete_term(term);
658        Ok(())
659    }
660
661    /// Commit pending index changes and reload the reader.
662    pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
663        let writer = writer.as_mut().ok_or_else(|| {
664            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
665        })?;
666        writer
667            .commit()
668            .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
669        self.reader
670            .reload()
671            .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
672        Ok(())
673    }
674
675    /// Search for memories matching the query text.
676    /// Returns results sorted by BM25 score (descending).
677    ///
678    /// The query is stripped of stopwords and sanitized to prevent Tantivy
679    /// query syntax injection.
680    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
681        let opts = SearchOptions {
682            limit,
683            ..SearchOptions::default()
684        };
685        self.search_opt(query, &opts)
686    }
687
688    /// Search for memories matching the query, optionally filtered by galaxy.
689    ///
690    /// The query is stripped of stopwords and sanitized to escape Tantivy
691    /// special characters (+, -, *, "", field syntax, boolean operators) that
692    /// could be used for query injection.
693    pub fn search_in_galaxy(
694        &self,
695        query: &str,
696        galaxy: Option<Galaxy>,
697        limit: usize,
698    ) -> Result<Vec<SearchResult>> {
699        let opts = SearchOptions {
700            limit,
701            galaxy,
702            ..SearchOptions::default()
703        };
704        self.search_opt(query, &opts)
705    }
706
707    /// Search with full recall-quality options (stopword stripping, score
708    /// thresholds, token-coverage filtering, galaxy filter).
709    ///
710    /// Pipeline:
711    /// 1. `strip_stopwords` — common English stopwords are removed.
712    /// 2. `sanitize_tantivy_query` — reserved query syntax is neutralized;
713    ///    plain terms (incl. hyphenated compounds) pass through so the
714    ///    tokenizer can split them into phrase matches.
715    /// 3. OR query across `content` + `tags` (broader recall than
716    ///    conjunction, filtered by token-coverage in step 5).
717    /// 4. Hits below `min_score` (absolute) or `relative_floor * top_score`
718    ///    are dropped.
719    /// 5. Token-coverage floor: for queries with ≥ 3 terms, at least 2
720    ///    must appear in the content (stemming-aware).  Documents that
721    ///    pass the floor receive a coverage-ratio score boost.
722    /// 6. Output content is scrubbed of control characters.
723    pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
724        // Tantivy's TopDocs panics on limit 0 ("Limit must be greater than
725        // 0") — inside a server that panic kills the process (2026-09-19
726        // review: memory.search with limit 0 exited 101). Reject before the
727        // collector is built; the tool boundary reports the caller error,
728        // this guard protects every other path into the engine.
729        if opts.limit == 0 {
730            return Err(CoreError::InvalidArgs(
731                "search limit must be >= 1, got: 0".into(),
732            ));
733        }
734        let stripped = strip_stopwords(query);
735        let sanitized = sanitize_tantivy_query(&stripped);
736        if sanitized.trim().is_empty() {
737            return Ok(Vec::new());
738        }
739
740        let searcher = self.reader.searcher();
741
742        // Always use OR semantics.  The token-coverage floor below filters
743        // single-term noise that OR would otherwise let through.
744        let query_parser =
745            QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
746
747        let parsed = parse_query_with_fallback(&query_parser, &sanitized);
748
749        let collector = TopDocs::with_limit(opts.limit).order_by_score();
750
751        let top_docs = searcher
752            .search(&parsed, &collector)
753            .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
754
755        let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
756        let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
757        let relative_floor = opts
758            .relative_floor
759            .map_or(f32::MIN, |ratio| top_score * ratio);
760
761        // Token-coverage floor: with OR semantics a document matching any
762        // single common term would otherwise qualify.  For queries with
763        // ≥ 3 terms, require at least 2 to appear in the content — but count
764        // only tokens that exist in the index at all.  A question word the
765        // corpus never contains ("many" in "how many capabilities are
766        // there?") must not raise the floor and filter every valid hit.
767        let query_tokens = query_stem_tokens(&stripped);
768        let present_tokens = query_tokens
769            .iter()
770            .filter(|token| {
771                searcher
772                    .doc_freq(&Term::from_field_text(self.field_content, token))
773                    .unwrap_or(0)
774                    > 0
775            })
776            .count();
777        let coverage_floor = if query_tokens.len() >= 3 && present_tokens >= 2 {
778            2
779        } else {
780            1
781        };
782
783        let mut results = Vec::new();
784        for (score, doc_address) in top_docs {
785            // Score floors: reject weak matches before touching the document.
786            if score < absolute_floor || score < relative_floor {
787                continue;
788            }
789
790            let doc: TantivyDocument = searcher
791                .doc(doc_address)
792                .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
793
794            let memory_id = doc
795                .get_first(self.field_id)
796                .and_then(|v| v.as_str())
797                .unwrap_or("")
798                .to_string();
799
800            let doc_galaxy = doc
801                .get_first(self.field_galaxy)
802                .and_then(|v| v.as_str())
803                .unwrap_or("")
804                .to_string();
805
806            if let Some(g) = opts.galaxy {
807                if doc_galaxy != g.db_name() {
808                    continue;
809                }
810            }
811
812            let content = doc
813                .get_first(self.field_content)
814                .and_then(|v| v.as_str())
815                .unwrap_or("")
816                .to_string();
817
818            if coverage_floor > 1 {
819                let hits = count_token_hits(&content, &stripped);
820                if hits < coverage_floor {
821                    continue;
822                }
823            }
824
825            // Coverage-ratio boost: documents covering more query tokens
826            // are more relevant.  Boost = 1 + 0.1 * (hits / total).
827            let boosted_score = if query_tokens.is_empty() {
828                score
829            } else {
830                let hits = count_token_hits(&content, &stripped);
831                let ratio = hits as f32 / query_tokens.len() as f32;
832                score * 0.1f32.mul_add(ratio, 1.0)
833            };
834
835            results.push(SearchResult {
836                memory_id,
837                galaxy: doc_galaxy,
838                score: boosted_score,
839                normalized_score: 0.0, // set after re-sort
840                content: scrub_text(&content),
841            });
842        }
843
844        // Re-sort by boosted score (coverage boost may have re-ordered).
845        results.sort_by(|a, b| {
846            b.score
847                .partial_cmp(&a.score)
848                .unwrap_or(std::cmp::Ordering::Equal)
849        });
850
851        // Normalize relative to the top boosted score.
852        let top_boosted = results.first().map_or(0.0, |r| r.score);
853        for r in &mut results {
854            r.normalized_score = if top_boosted > 0.0 {
855                r.score / top_boosted
856            } else {
857                0.0
858            };
859        }
860
861        Ok(results)
862    }
863
864    /// Search and return memory IDs only (for integration with `MemoryStore`).
865    pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
866        let results = self.search(query, limit)?;
867        Ok(results
868            .into_iter()
869            .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
870            .collect())
871    }
872}
873
874/// Parse a sanitized query, falling back to lenient parsing when the strict
875/// parser rejects it.
876///
877/// [`sanitize_tantivy_query`] neutralizes known syntax, but the parser can
878/// still reject input it does not anticipate (for example a term whose
879/// quoting produces a dangling escape). Lenient parsing turns unparseable
880/// fragments into match-nothing clauses, so a malformed query degrades to a
881/// partial search instead of failing the request.
882fn parse_query_with_fallback(parser: &QueryParser, query: &str) -> Box<dyn tantivy::query::Query> {
883    match parser.parse_query(query) {
884        Ok(parsed) => parsed,
885        Err(_) => parser.parse_query_lenient(query).0,
886    }
887}
888
889/// Sanitize a user-provided query string for Tantivy's query parser.
890///
891/// Tantivy's query parser supports special syntax that could be abused:
892/// - `*` wildcard matches all terms (DoS)
893/// - `+`, `-`, `NOT`, `OR`, `AND` boolean operators
894/// - `"phrase"` exact phrase queries
895/// - `field:value` field-scoped queries
896/// - `(`, `)` grouping
897/// - `\` escape character
898/// - `:` field separator
899///
900/// Terms are only wrapped in double quotes when they contain reserved syntax
901/// (or are uppercase boolean operators). Plain terms — including hyphenated
902/// compounds like `antigravity-project-test` — pass through unquoted so the
903/// tokenizer can split them into phrase matches. Terms without any
904/// alphanumeric characters are dropped entirely.
905#[must_use]
906pub fn sanitize_tantivy_query(input: &str) -> String {
907    // If empty, return as-is
908    if input.trim().is_empty() {
909        return String::new();
910    }
911
912    input
913        .split_whitespace()
914        .filter(|term| term.chars().any(char::is_alphanumeric))
915        .map(|term| {
916            if term_needs_quoting(term) {
917                // Escape backslashes first, then embedded double quotes. A
918                // trailing backslash would otherwise escape the closing quote
919                // and produce an unterminated phrase (a parse error).
920                let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
921                format!("\"{escaped}\"")
922            } else {
923                term.to_string()
924            }
925        })
926        .collect::<Vec<_>>()
927        .join(" ")
928}
929
930/// Whether a query term needs quoting to neutralize Tantivy query syntax.
931#[must_use]
932fn term_needs_quoting(term: &str) -> bool {
933    if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
934        return true;
935    }
936    if term == "AND" || term == "OR" || term == "NOT" {
937        return true;
938    }
939    if term.contains("&&") || term.contains("||") {
940        return true;
941    }
942    term.chars().any(|c| {
943        matches!(
944            c,
945            '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
946        )
947    })
948}
949
950/// Strip common English stopwords from a query string.
951///
952/// Tokens are compared case-insensitively against [`STOPWORDS`] after
953/// trimming surrounding punctuation, so a question ending in `?`
954/// ("... are there?") strips exactly like the same words without it.
955#[must_use]
956pub fn strip_stopwords(query: &str) -> String {
957    query
958        .split_whitespace()
959        .filter(|term| {
960            let normalized: String = term
961                .chars()
962                .filter(|character| character.is_alphanumeric())
963                .collect::<String>()
964                .to_lowercase();
965            !normalized.is_empty() && !STOPWORDS.contains(&normalized.as_str())
966        })
967        .collect::<Vec<_>>()
968        .join(" ")
969}
970
971/// Unique lowercase stemmed tokens of a stopword-stripped query.
972/// Uses [`simple_stem`] so that coverage matching aligns with the en_stem
973/// tokenizer used at index time.
974#[must_use]
975fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
976    stem_tokens(stripped_query)
977}
978
979/// Normalize text into the same punctuation-delimited tokens on both sides
980/// of the coverage comparison. This keeps possessives and hyphenated terms
981/// from becoming query-only tokens or standalone one-character fragments.
982#[must_use]
983fn stem_tokens(text: &str) -> Vec<String> {
984    let mut tokens: Vec<String> = Vec::new();
985    for term in text
986        .split(|c: char| !c.is_alphanumeric())
987        .filter(|term| term.len() > 1)
988    {
989        let stemmed = simple_stem(&term.to_lowercase());
990        if !tokens.contains(&stemmed) {
991            tokens.push(stemmed);
992        }
993    }
994    tokens
995}
996
997/// Lightweight suffix-stripping stemmer that approximates the Porter stemmer
998/// used by Tantivy's `en_stem` tokenizer.  Handles the common English
999/// inflections (-s, -es, -ed, -ing, -ly, -ies, -ied) without pulling in a
1000/// full stemming crate.  This is intentionally conservative — false
1001/// negatives (under-stemming) only make coverage stricter, never looser.
1002#[must_use]
1003fn simple_stem(word: &str) -> String {
1004    if word.len() <= 3 {
1005        return word.to_string();
1006    }
1007    // Order matters: check longer suffixes first.
1008    for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
1009        if let Some(stem) = word.strip_suffix(suffix) {
1010            // "ies" / "ied" → restore "y" (stories → story, carried → carry)
1011            if suffix == "ies" || suffix == "ied" {
1012                return format!("{stem}y");
1013            }
1014            // Don't produce a 1-char stem ("is" → "i")
1015            if stem.len() >= 2 {
1016                return stem.to_string();
1017            }
1018        }
1019    }
1020    word.to_string()
1021}
1022
1023/// Count how many query tokens (after stemming) appear as whole words in the
1024/// content.  Uses [`simple_stem`] on both sides so that "graduate" matches
1025/// "graduated", mirroring the en_stem tokenizer used at index time.
1026#[must_use]
1027fn count_token_hits(content: &str, stripped_query: &str) -> usize {
1028    let query_tokens = query_stem_tokens(stripped_query);
1029    if query_tokens.is_empty() {
1030        return 0;
1031    }
1032    let content_stems: std::collections::HashSet<String> =
1033        stem_tokens(content).into_iter().collect();
1034    query_tokens
1035        .iter()
1036        .filter(|t| content_stems.contains(*t))
1037        .count()
1038}
1039
1040/// Printable-character ratio used by the index/admission gate.
1041///
1042/// Tab, newline, and carriage return are **formatting whitespace, not
1043/// debris** — they count as printable, aligned with the byte-level ingest
1044/// gate (`wm_mcp::ingest::binary_content`), which already treats those
1045/// three bytes as printable. Any other Unicode control character counts
1046/// against the ratio. Empty content returns 1.0 (vacuously clean); callers
1047/// reject emptiness separately.
1048///
1049/// 2026-09-19 benchmark finding: the old definition counted line breaks as
1050/// unprintable, so code/formatting-heavy memories (hex-color lists, HTML
1051/// and jQuery snippets) failed admission even though they are exactly the
1052/// content a coding-agent memory exists to keep.
1053#[must_use]
1054pub fn printable_ratio(content: &str) -> f32 {
1055    let total = content.chars().count();
1056    if total == 0 {
1057        return 1.0;
1058    }
1059    let printable = content
1060        .chars()
1061        .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
1062        .count();
1063    printable as f32 / total as f32
1064}
1065
1066/// Prepare content for indexing.
1067///
1068/// Returns `None` when the content is not clean text and must be skipped:
1069/// - empty / whitespace-only content
1070/// - contains a null byte (binary serialization artifact)
1071/// - printable-char ratio below [`MIN_PRINTABLE_RATIO`] (tab/newline/CR
1072///   count as printable — see [`printable_ratio`])
1073///
1074/// Otherwise returns the content scrubbed of control characters and capped
1075/// at [`MAX_INDEX_CONTENT_LEN`] chars.
1076#[must_use]
1077pub fn sanitize_content_for_index(content: &str) -> Option<String> {
1078    if content.trim().is_empty() {
1079        return None;
1080    }
1081    if content.as_bytes().contains(&0) {
1082        return None;
1083    }
1084    if printable_ratio(content) < MIN_PRINTABLE_RATIO {
1085        return None;
1086    }
1087
1088    let cleaned = scrub_text(content);
1089    let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1090    if capped.trim().is_empty() {
1091        None
1092    } else {
1093        Some(capped)
1094    }
1095}
1096
1097/// Scrub text for output: replace control characters (except newline, tab,
1098/// carriage return) with a space, and cap the length at
1099/// [`MAX_INDEX_CONTENT_LEN`].
1100#[must_use]
1101pub fn scrub_text(content: &str) -> String {
1102    let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1103    for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1104        if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1105            out.push(' ');
1106        } else {
1107            out.push(c);
1108        }
1109    }
1110    out
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use tempfile::tempdir;
1117
1118    fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1119        let tmp = tempdir().unwrap();
1120        let engine = SearchEngine::open(tmp.path()).unwrap();
1121        (tmp, engine)
1122    }
1123
1124    /// 2026-09-19 review: limit 0 reached Tantivy's `TopDocs` and panicked
1125    /// the server process (exit 101). The engine must return a caller error
1126    /// before the collector is built — this guard covers every path in.
1127    #[test]
1128    fn search_rejects_zero_limit_without_panicking() {
1129        let (_tmp, engine) = open_engine();
1130        let err = engine.search("anything", 0).unwrap_err();
1131        assert!(err.to_string().contains("limit"), "{err}");
1132        let err = engine
1133            .search_in_galaxy("anything", Some(Galaxy::Codex), 0)
1134            .unwrap_err();
1135        assert!(err.to_string().contains("limit"), "{err}");
1136        let opts = SearchOptions {
1137            limit: 0,
1138            ..SearchOptions::default()
1139        };
1140        assert!(engine.search_opt("anything", &opts).is_err());
1141    }
1142
1143    /// Write a legacy one-field index into `dir`, simulating a store created
1144    /// by an older WhiteMagic version with a different Tantivy schema.
1145    fn write_incompatible_index(dir: &Path) {
1146        std::fs::create_dir_all(dir).unwrap();
1147        let mut builder = Schema::builder();
1148        builder.add_text_field("legacy", STRING | STORED);
1149        let schema = builder.build();
1150        let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1151        Index::open_or_create(directory, schema).unwrap();
1152    }
1153
1154    #[test]
1155    fn open_migrates_incompatible_schema() {
1156        let tmp = tempdir().unwrap();
1157        let dir = tmp.path().join("tantivy");
1158        write_incompatible_index(&dir);
1159
1160        let engine = SearchEngine::open(&dir).unwrap();
1161        assert!(
1162            engine.schema_migrated(),
1163            "incompatible schema must trigger migration"
1164        );
1165
1166        // The old index must be preserved as a .schema-mismatch sibling.
1167        let backups: Vec<_> = std::fs::read_dir(tmp.path())
1168            .unwrap()
1169            .filter_map(std::result::Result::ok)
1170            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1171            .collect();
1172        assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1173
1174        // The fresh index must be writable and searchable.
1175        let mut writer = engine.writer().unwrap();
1176        engine
1177            .add_document(
1178                &mut writer,
1179                "33333333-3333-3333-3333-333333333333",
1180                "codex",
1181                "fresh index after migration",
1182                &[],
1183                1700000000,
1184            )
1185            .unwrap();
1186        engine.commit(&mut writer).unwrap();
1187        let results = engine.search("fresh index", 10).unwrap();
1188        assert_eq!(results.len(), 1);
1189    }
1190
1191    #[test]
1192    fn writer_lock_error_names_path_and_hint() {
1193        // B1: LockBusy used to surface bare ("Failed to acquire Lockfile:
1194        // LockBusy") with no path and no hint — a stray `wm serve` cost a
1195        // debug session to find.
1196        let err = format_writer_lock_error(
1197            "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1198            Path::new("/store/x/tantivy"),
1199        );
1200        assert!(
1201            err.contains("/store/x/tantivy"),
1202            "must name the index path: {err}"
1203        );
1204        assert!(
1205            err.contains("pgrep -af wm"),
1206            "must include the diagnostic hint: {err}"
1207        );
1208        assert!(
1209            err.contains("--readonly"),
1210            "must offer the readonly alternative: {err}"
1211        );
1212
1213        // Non-lock errors pass through unchanged.
1214        let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1215        assert!(other.starts_with("Tantivy writer: disk full"));
1216        assert!(!other.contains("pgrep"));
1217    }
1218
1219    #[test]
1220    fn open_readonly_rejects_incompatible_schema() {
1221        let tmp = tempdir().unwrap();
1222        let dir = tmp.path().join("tantivy");
1223        write_incompatible_index(&dir);
1224
1225        let err = match SearchEngine::open_readonly(&dir) {
1226            Ok(_) => panic!("read-only open must reject an incompatible schema"),
1227            Err(e) => e,
1228        };
1229        assert!(
1230            format!("{err}").contains("wm reindex"),
1231            "read-only mismatch must point at wm reindex, got: {err}"
1232        );
1233
1234        // Nothing was moved: the incompatible index is still in place.
1235        let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1236            .unwrap()
1237            .filter_map(std::result::Result::ok)
1238            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1239            .collect();
1240        assert!(siblings.is_empty(), "read-only open must not migrate");
1241    }
1242
1243    #[test]
1244    fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1245        let tmp = tempdir().unwrap();
1246        let dir = tmp.path().join("tantivy");
1247        std::fs::create_dir_all(&dir).unwrap();
1248
1249        let err = match SearchEngine::open_readonly(&dir) {
1250            Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1251            Err(err) => err,
1252        };
1253        assert!(format!("{err}").contains("readonly open-existing"));
1254        assert!(
1255            std::fs::read_dir(&dir).unwrap().next().is_none(),
1256            "readonly open must not materialize Tantivy metadata or segments"
1257        );
1258    }
1259
1260    #[test]
1261    fn reopen_matching_schema_not_migrated() {
1262        let tmp = tempdir().unwrap();
1263        let dir = tmp.path().join("tantivy");
1264        std::fs::create_dir_all(&dir).unwrap();
1265
1266        let first = SearchEngine::open(&dir).unwrap();
1267        assert!(!first.schema_migrated());
1268        drop(first); // release the tantivy writer lock before reopening
1269
1270        let second = SearchEngine::open(&dir).unwrap();
1271        assert!(
1272            !second.schema_migrated(),
1273            "matching schema must not migrate"
1274        );
1275        drop(second);
1276
1277        let third = SearchEngine::open_readonly(&dir).unwrap();
1278        assert!(!third.schema_migrated());
1279    }
1280
1281    #[test]
1282    fn index_and_search_basic() {
1283        let (_tmp, engine) = open_engine();
1284        let mut writer = engine.writer().unwrap();
1285
1286        engine
1287            .add_document(
1288                &mut writer,
1289                "11111111-1111-1111-1111-111111111111",
1290                "codex",
1291                "The Rust programming language is fast and safe",
1292                &["rust".into(), "programming".into()],
1293                1700000000,
1294            )
1295            .unwrap();
1296        engine
1297            .add_document(
1298                &mut writer,
1299                "22222222-2222-2222-2222-222222222222",
1300                "codex",
1301                "Python is great for data science",
1302                &["python".into(), "data".into()],
1303                1700000001,
1304            )
1305            .unwrap();
1306        engine.commit(&mut writer).unwrap();
1307
1308        let results = engine.search("rust", 10).unwrap();
1309        assert!(!results.is_empty());
1310        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1311    }
1312
1313    #[test]
1314    fn search_by_tag() {
1315        let (_tmp, engine) = open_engine();
1316        let mut writer = engine.writer().unwrap();
1317
1318        engine
1319            .add_document(
1320                &mut writer,
1321                "11111111-1111-1111-1111-111111111111",
1322                "codex",
1323                "memory about systems",
1324                &["rust".into()],
1325                1700000000,
1326            )
1327            .unwrap();
1328        engine
1329            .add_document(
1330                &mut writer,
1331                "22222222-2222-2222-2222-222222222222",
1332                "codex",
1333                "memory about cooking",
1334                &["food".into()],
1335                1700000001,
1336            )
1337            .unwrap();
1338        engine.commit(&mut writer).unwrap();
1339
1340        let results = engine.search("rust", 10).unwrap();
1341        assert_eq!(results.len(), 1);
1342        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1343    }
1344
1345    #[test]
1346    fn search_filtered_by_galaxy() {
1347        let (_tmp, engine) = open_engine();
1348        let mut writer = engine.writer().unwrap();
1349
1350        engine
1351            .add_document(
1352                &mut writer,
1353                "11111111-1111-1111-1111-111111111111",
1354                "codex",
1355                "important knowledge",
1356                &[],
1357                1700000000,
1358            )
1359            .unwrap();
1360        engine
1361            .add_document(
1362                &mut writer,
1363                "22222222-2222-2222-2222-222222222222",
1364                "research",
1365                "important findings",
1366                &[],
1367                1700000001,
1368            )
1369            .unwrap();
1370        engine.commit(&mut writer).unwrap();
1371
1372        let results = engine
1373            .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1374            .unwrap();
1375        assert_eq!(results.len(), 1);
1376        assert_eq!(results[0].galaxy, "codex");
1377    }
1378
1379    #[test]
1380    fn delete_document_from_index() {
1381        let (_tmp, engine) = open_engine();
1382        let mut writer = engine.writer().unwrap();
1383
1384        engine
1385            .add_document(
1386                &mut writer,
1387                "11111111-1111-1111-1111-111111111111",
1388                "codex",
1389                "deletable content",
1390                &[],
1391                1700000000,
1392            )
1393            .unwrap();
1394        engine.commit(&mut writer).unwrap();
1395
1396        let results = engine.search("deletable", 10).unwrap();
1397        assert_eq!(results.len(), 1);
1398
1399        engine
1400            .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1401            .unwrap();
1402        engine.commit(&mut writer).unwrap();
1403
1404        let results = engine.search("deletable", 10).unwrap();
1405        assert_eq!(results.len(), 0);
1406    }
1407
1408    #[test]
1409    fn search_empty_index() {
1410        let (_tmp, engine) = open_engine();
1411        let results = engine.search("anything", 10).unwrap();
1412        assert!(results.is_empty());
1413    }
1414
1415    #[test]
1416    fn search_ids_returns_uuids() {
1417        let (_tmp, engine) = open_engine();
1418        let mut writer = engine.writer().unwrap();
1419
1420        engine
1421            .add_document(
1422                &mut writer,
1423                "11111111-1111-1111-1111-111111111111",
1424                "codex",
1425                "unique content about rust",
1426                &[],
1427                1700000000,
1428            )
1429            .unwrap();
1430        engine.commit(&mut writer).unwrap();
1431
1432        let ids = engine.search_ids("rust", 10).unwrap();
1433        assert_eq!(ids.len(), 1);
1434        assert_eq!(
1435            ids[0],
1436            uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1437        );
1438    }
1439
1440    // ── Tantivy query injection tests ───────────────────────────────
1441
1442    #[test]
1443    fn sanitize_leaves_plain_terms_unquoted() {
1444        let result = sanitize_tantivy_query("hello world");
1445        assert_eq!(result, "hello world");
1446    }
1447
1448    #[test]
1449    fn sanitize_drops_punct_only_terms() {
1450        let result = sanitize_tantivy_query("*");
1451        assert_eq!(result, "");
1452        // Should not match all documents when parsed
1453    }
1454
1455    #[test]
1456    fn sanitize_escapes_boolean_operators() {
1457        let result = sanitize_tantivy_query("NOT secret");
1458        assert_eq!(result, "\"NOT\" secret");
1459    }
1460
1461    #[test]
1462    fn sanitize_escapes_field_syntax() {
1463        let result = sanitize_tantivy_query("content:secret");
1464        assert_eq!(result, "\"content:secret\"");
1465    }
1466
1467    #[test]
1468    fn sanitize_escapes_quotes() {
1469        let result = sanitize_tantivy_query("test\"injection");
1470        assert!(
1471            result.contains("\\\""),
1472            "embedded quotes should be escaped: {result}"
1473        );
1474    }
1475
1476    #[test]
1477    fn sanitize_escapes_trailing_backslash_token() {
1478        // A term ending in a backslash used to become "abc\" — the dangling
1479        // escape swallowed the closing quote and failed the query parser.
1480        let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1481        assert_eq!(
1482            result, "\"C:\\\\Users\\\\temp\\\\\"",
1483            "backslashes must be doubled inside quoted terms"
1484        );
1485    }
1486
1487    #[test]
1488    fn lenient_fallback_never_fails_on_malformed_input() {
1489        let (_tmp, engine) = open_engine();
1490        let parser =
1491            QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1492        let searcher = engine.reader.searcher();
1493        let collector = TopDocs::with_limit(1).order_by_score();
1494        for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1495            let parsed = parse_query_with_fallback(&parser, malformed);
1496            searcher
1497                .search(&parsed, &collector)
1498                .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1499        }
1500    }
1501
1502    #[test]
1503    fn sanitize_empty_returns_empty() {
1504        assert_eq!(sanitize_tantivy_query(""), "");
1505        assert_eq!(sanitize_tantivy_query("   "), "");
1506    }
1507
1508    #[test]
1509    fn sanitize_preserves_alphanumeric() {
1510        let result = sanitize_tantivy_query("rust programming 2024");
1511        assert_eq!(result, "rust programming 2024");
1512    }
1513
1514    #[test]
1515    fn sanitize_preserves_hyphenated_compounds() {
1516        let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1517        assert_eq!(result, "antigravity antigravity-project-test");
1518    }
1519
1520    // ── Stopword tests ──────────────────────────────────────────────
1521
1522    #[test]
1523    fn strip_stopwords_removes_common_words() {
1524        assert_eq!(
1525            strip_stopwords("smoke test from wmClient"),
1526            "smoke test wmClient"
1527        );
1528        assert_eq!(strip_stopwords("the from and or"), "");
1529        assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1530        assert_eq!(strip_stopwords(""), "");
1531    }
1532
1533    #[test]
1534    fn strip_stopwords_is_case_insensitive() {
1535        assert_eq!(strip_stopwords("FROM The And"), "");
1536    }
1537
1538    // ── Index-time sanitization tests ───────────────────────────────
1539
1540    #[test]
1541    fn sanitize_content_skips_null_bytes() {
1542        let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1543        assert!(sanitize_content_for_index(content).is_none());
1544    }
1545
1546    #[test]
1547    fn sanitize_content_skips_low_printable_ratio() {
1548        // 5 control chars out of 11 → ratio 0.55 < 0.9 → skip
1549        let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1550        assert!(sanitize_content_for_index(content).is_none());
1551    }
1552
1553    #[test]
1554    fn sanitize_content_accepts_code_and_formatting_heavy_text() {
1555        // 2026-09-19 benchmark regression: code/formatting-heavy turns were
1556        // rejected because every line break counted as unprintable. Tab,
1557        // newline, and CR are formatting whitespace — they must not fail
1558        // admission (the byte-level ingest gate already treats them as
1559        // printable bytes).
1560        let hex_list = "Casper\n#ACBFCD\n\nPickled Bluewood\n#324558\n\nComet\n#545B70\n";
1561        assert!(sanitize_content_for_index(hex_list).is_some());
1562        let html = "Sure, here's how:\n```html\n<!DOCTYPE html>\n<html>\n  <body>\n \n \n  </body>\n</html>\n```";
1563        assert!(sanitize_content_for_index(html).is_some());
1564        // Genuine binary control chars still fail the ratio.
1565        let binary = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1566        assert!(sanitize_content_for_index(binary).is_none());
1567    }
1568
1569    #[test]
1570    fn printable_ratio_treats_line_breaks_as_printable() {
1571        assert!((printable_ratio("a\nb\tc\rd") - 1.0).abs() < f32::EPSILON);
1572        assert!(printable_ratio("\u{01}\u{02}\u{03}\u{04}\u{05}hello") < 0.9);
1573        assert!((printable_ratio("") - 1.0).abs() < f32::EPSILON);
1574    }
1575
1576    #[test]
1577    fn sanitize_content_scrubs_and_caps() {
1578        // A stray control char does not disqualify clean text — it is scrubbed.
1579        let content = "clean text\u{01}with one control char";
1580        let cleaned = sanitize_content_for_index(content).unwrap();
1581        assert!(!cleaned.contains('\u{01}'));
1582        assert!(cleaned.starts_with("clean text with one control char"));
1583
1584        let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1585        let capped = sanitize_content_for_index(&long).unwrap();
1586        assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1587    }
1588
1589    #[test]
1590    fn sanitize_content_skips_empty() {
1591        assert!(sanitize_content_for_index("").is_none());
1592        assert!(sanitize_content_for_index("   ").is_none());
1593    }
1594
1595    #[test]
1596    fn scrub_text_replaces_control_chars() {
1597        let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1598        assert_eq!(result, "a b\nc\td e");
1599    }
1600
1601    #[test]
1602    fn add_document_skips_binary_content() {
1603        let (_tmp, engine) = open_engine();
1604        let mut writer = engine.writer().unwrap();
1605
1606        engine
1607            .add_document(
1608                &mut writer,
1609                "11111111-1111-1111-1111-111111111111",
1610                "codex",
1611                "\u{00}\u{01}\u{02}raw serialized bytes",
1612                &[],
1613                1700000000,
1614            )
1615            .unwrap();
1616        engine
1617            .add_document(
1618                &mut writer,
1619                "22222222-2222-2222-2222-222222222222",
1620                "codex",
1621                "clean searchable text",
1622                &[],
1623                1700000001,
1624            )
1625            .unwrap();
1626        engine.commit(&mut writer).unwrap();
1627
1628        // The binary doc must not be searchable; the clean one must be.
1629        let results = engine.search("serialized", 10).unwrap();
1630        assert!(results.is_empty(), "binary content must not be indexed");
1631
1632        let results = engine.search("clean", 10).unwrap();
1633        assert_eq!(results.len(), 1);
1634        assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1635    }
1636
1637    // ── Score-threshold tests ───────────────────────────────────────
1638
1639    fn index_alpha_pair(engine: &SearchEngine) {
1640        let mut writer = engine.writer().unwrap();
1641        // Two docs both containing "alpha"; the short one scores higher
1642        // (BM25 length normalization).
1643        engine
1644            .add_document(
1645                &mut writer,
1646                "11111111-1111-1111-1111-111111111111",
1647                "codex",
1648                "alpha",
1649                &[],
1650                1700000000,
1651            )
1652            .unwrap();
1653        let filler = format!("alpha {}", "zzz ".repeat(400));
1654        engine
1655            .add_document(
1656                &mut writer,
1657                "22222222-2222-2222-2222-222222222222",
1658                "codex",
1659                &filler,
1660                &[],
1661                1700000001,
1662            )
1663            .unwrap();
1664        engine.commit(&mut writer).unwrap();
1665    }
1666
1667    #[test]
1668    fn strip_stopwords_ignores_trailing_punctuation() {
1669        assert_eq!(
1670            strip_stopwords("how many capabilities are there?"),
1671            "many capabilities"
1672        );
1673    }
1674
1675    /// Regression: a natural-language question whose words are mostly absent
1676    /// from the corpus returned zero results. The trailing "?" kept "there?"
1677    /// from being recognized as a stopword, which pushed the query over the
1678    /// token-coverage floor; the floor now counts only index-present tokens.
1679    #[test]
1680    fn question_with_absent_token_still_matches() {
1681        let (_tmp, engine) = open_engine();
1682        let mut writer = engine.writer().unwrap();
1683        engine
1684            .add_document(
1685                &mut writer,
1686                "33333333-3333-3333-3333-333333333333",
1687                "codex",
1688                "unique zebra content",
1689                &[],
1690                1700000002,
1691            )
1692            .unwrap();
1693        engine.commit(&mut writer).unwrap();
1694
1695        // "many", "stripes", and "exist" are absent from the corpus; the old
1696        // all-query-token floor (2 of 4) filtered the only valid hit.
1697        let results = engine.search("how many zebra stripes exist?", 5).unwrap();
1698        assert_eq!(
1699            results.len(),
1700            1,
1701            "the single valid hit must survive the coverage floor"
1702        );
1703        assert_eq!(results[0].memory_id, "33333333-3333-3333-3333-333333333333");
1704    }
1705
1706    #[test]
1707    fn search_absolute_min_score_filters_weak_matches() {
1708        let (_tmp, engine) = open_engine();
1709        index_alpha_pair(&engine);
1710
1711        let base = engine.search("alpha", 10).unwrap();
1712        assert_eq!(base.len(), 2);
1713        let (hi, lo) = if base[0].score >= base[1].score {
1714            (base[0].score, base[1].score)
1715        } else {
1716            (base[1].score, base[0].score)
1717        };
1718        assert!(
1719            hi > lo,
1720            "short doc should outscore long doc (hi={hi}, lo={lo})"
1721        );
1722        let mid = f32::midpoint(hi, lo);
1723
1724        let opts = SearchOptions {
1725            limit: 10,
1726            min_score: Some(mid),
1727            ..SearchOptions::default()
1728        };
1729        let filtered = engine.search_opt("alpha", &opts).unwrap();
1730        assert_eq!(filtered.len(), 1);
1731        assert!((filtered[0].score - hi).abs() < 1e-3);
1732    }
1733
1734    #[test]
1735    fn search_relative_floor_filters_weak_matches() {
1736        let (_tmp, engine) = open_engine();
1737        index_alpha_pair(&engine);
1738
1739        let opts = SearchOptions {
1740            limit: 10,
1741            relative_floor: Some(0.5),
1742            ..SearchOptions::default()
1743        };
1744        let filtered = engine.search_opt("alpha", &opts).unwrap();
1745        assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1746        assert_eq!(
1747            filtered[0].memory_id,
1748            "11111111-1111-1111-1111-111111111111"
1749        );
1750        assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1751    }
1752
1753    #[test]
1754    fn search_all_results_normalized() {
1755        let (_tmp, engine) = open_engine();
1756        index_alpha_pair(&engine);
1757
1758        let results = engine.search("alpha", 10).unwrap();
1759        assert_eq!(results.len(), 2);
1760        assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1761        for r in &results[1..] {
1762            assert!(r.normalized_score <= 1.0);
1763            assert!(r.normalized_score > 0.0);
1764        }
1765    }
1766
1767    #[test]
1768    fn search_stemming_matches_morphological_variants() {
1769        // The en_stem tokenizer should match morphological variants:
1770        // "graduate" (query) ↔ "graduated" (indexed content).
1771        let (_tmp, engine) = open_engine();
1772        let mut writer = engine.writer().unwrap();
1773
1774        engine
1775            .add_document(
1776                &mut writer,
1777                "11111111-1111-1111-1111-111111111111",
1778                "codex",
1779                "I graduated with a degree in Business Administration",
1780                &[],
1781                1700000000,
1782            )
1783            .unwrap();
1784        engine.commit(&mut writer).unwrap();
1785
1786        // Query with present tense "graduate" should match past tense "graduated"
1787        let results = engine.search("graduate", 10).unwrap();
1788        assert_eq!(results.len(), 1);
1789        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1790
1791        // Query with "degrees" should match "degree"
1792        let results = engine.search("degrees", 10).unwrap();
1793        assert_eq!(results.len(), 1);
1794    }
1795
1796    #[test]
1797    fn search_incident_query_returns_only_relevant() {
1798        // Mirrors the 2026-08-11 incident: `memory.hybrid_recall`
1799        // query "smoke test from wmClient" must NOT return unrelated memories.
1800        let (_tmp, engine) = open_engine();
1801        let mut writer = engine.writer().unwrap();
1802
1803        let smoke_id = "11111111-1111-1111-1111-111111111111";
1804        engine
1805            .add_document(
1806                &mut writer,
1807                smoke_id,
1808                "codex",
1809                "smoke test from wmClient: verify recall works",
1810                &[],
1811                1700000000,
1812            )
1813            .unwrap();
1814        let unrelated = [
1815            "NES Evolution and Impact: a history of the console wars",
1816            "Insights on The Gateless Gate: koans and zen practice",
1817            "What the tweet is really saying: a thread analysis",
1818        ];
1819        for (i, content) in (1i64..).zip(unrelated.iter()) {
1820            engine
1821                .add_document(
1822                    &mut writer,
1823                    &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1824                    "codex",
1825                    content,
1826                    &[],
1827                    1700000000 + i,
1828                )
1829                .unwrap();
1830        }
1831        engine.commit(&mut writer).unwrap();
1832
1833        let results = engine.search("smoke test from wmClient", 20).unwrap();
1834        assert_eq!(
1835            results.len(),
1836            1,
1837            "only the smoke memory should match: {results:?}"
1838        );
1839        assert_eq!(results[0].memory_id, smoke_id);
1840        assert!(results[0].content.contains("smoke test"));
1841    }
1842
1843    #[test]
1844    fn search_project_compound_query() {
1845        // "antigravity antigravity-project-test" must find the project memory.
1846        let (_tmp, engine) = open_engine();
1847        let mut writer = engine.writer().unwrap();
1848
1849        engine
1850            .add_document(
1851                &mut writer,
1852                "11111111-1111-1111-1111-111111111111",
1853                "codex",
1854                "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1855                &["project_antigravity-project-test".into()],
1856                1700000000,
1857            )
1858            .unwrap();
1859        engine.commit(&mut writer).unwrap();
1860
1861        let results = engine
1862            .search("antigravity antigravity-project-test", 10)
1863            .unwrap();
1864        assert!(
1865            !results.is_empty(),
1866            "project compound query must match the antigravity memory"
1867        );
1868        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1869    }
1870
1871    #[test]
1872    fn or_default_filters_partial_matches_via_coverage() {
1873        let (_tmp, engine) = open_engine();
1874        let mut writer = engine.writer().unwrap();
1875
1876        engine
1877            .add_document(
1878                &mut writer,
1879                "11111111-1111-1111-1111-111111111111",
1880                "codex",
1881                "alpha beta gamma delta",
1882                &[],
1883                1700000000,
1884            )
1885            .unwrap();
1886        engine
1887            .add_document(
1888                &mut writer,
1889                "22222222-2222-2222-2222-222222222222",
1890                "codex",
1891                "alpha only here",
1892                &[],
1893                1700000001,
1894            )
1895            .unwrap();
1896        engine.commit(&mut writer).unwrap();
1897
1898        // OR is now the default.  A 3-term query requires 2/3 token coverage,
1899        // so the "alpha only here" doc (1/3) is filtered out.
1900        let results = engine.search("alpha beta gamma", 10).unwrap();
1901        assert_eq!(results.len(), 1);
1902        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1903
1904        // With a 2-term query the floor is 1/2, so partial matches return.
1905        let results = engine.search("alpha beta", 10).unwrap();
1906        assert_eq!(results.len(), 2);
1907    }
1908
1909    #[test]
1910    fn coverage_is_case_insensitive() {
1911        let (_tmp, engine) = open_engine();
1912        let mut writer = engine.writer().unwrap();
1913        engine
1914            .add_document(
1915                &mut writer,
1916                "11111111-1111-1111-1111-111111111111",
1917                "codex",
1918                "Smoke Test for wmClient integration",
1919                &[],
1920                1700000000,
1921            )
1922            .unwrap();
1923        engine
1924            .add_document(
1925                &mut writer,
1926                "22222222-2222-2222-2222-222222222222",
1927                "codex",
1928                "test only here",
1929                &[],
1930                1700000001,
1931            )
1932            .unwrap();
1933        engine.commit(&mut writer).unwrap();
1934
1935        // 3 tokens: smoke + test + wmclient (case-insensitive stemming-aware
1936        // whole-word matching) — only the first doc covers 2/3; the "test
1937        // only" doc covers 1/3 and must be dropped even though it matched
1938        // via OR.
1939        let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1940        assert_eq!(results.len(), 1);
1941        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1942    }
1943
1944    #[test]
1945    fn coverage_matches_stemmed_variants() {
1946        // Stemming-aware coverage: "graduate" should match "graduated",
1947        // "classes" should match "class", mirroring the en_stem tokenizer.
1948        let (_tmp, engine) = open_engine();
1949        let mut writer = engine.writer().unwrap();
1950        engine
1951            .add_document(
1952                &mut writer,
1953                "11111111-1111-1111-1111-111111111111",
1954                "codex",
1955                "I graduated with a degree in Business Administration",
1956                &[],
1957                1700000000,
1958            )
1959            .unwrap();
1960        engine
1961            .add_document(
1962                &mut writer,
1963                "22222222-2222-2222-2222-222222222222",
1964                "codex",
1965                "degree only here",
1966                &[],
1967                1700000001,
1968            )
1969            .unwrap();
1970        engine.commit(&mut writer).unwrap();
1971
1972        // 2-term query "graduate degree" → coverage floor 1/2.
1973        // Doc 1: "graduated" stems to "graduat", "degree" stems to "degre" → 2/2.
1974        // Doc 2: "degree" → 1/2.
1975        // Both pass the 1/2 floor, but doc 1 should rank higher due to
1976        // the coverage-ratio boost (2/2 > 1/2).
1977        let results = engine.search("graduate degree", 10).unwrap();
1978        assert_eq!(results.len(), 2);
1979        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1980    }
1981
1982    #[test]
1983    fn coverage_normalizes_possessives_and_punctuation() {
1984        let query = "buy sister's birthday gift";
1985        assert_eq!(
1986            query_stem_tokens(query),
1987            ["buy", "sister", "birthday", "gift"]
1988        );
1989        assert_eq!(
1990            count_token_hits("I bought a dress for my sister birthday", query),
1991            2
1992        );
1993    }
1994
1995    #[test]
1996    fn search_stopword_only_query_returns_nothing() {
1997        let (_tmp, engine) = open_engine();
1998        let mut writer = engine.writer().unwrap();
1999        engine
2000            .add_document(
2001                &mut writer,
2002                "11111111-1111-1111-1111-111111111111",
2003                "codex",
2004                "some ordinary text",
2005                &[],
2006                1700000000,
2007            )
2008            .unwrap();
2009        engine.commit(&mut writer).unwrap();
2010
2011        let results = engine.search("the from and or", 10).unwrap();
2012        assert!(results.is_empty());
2013    }
2014
2015    #[test]
2016    fn wildcard_query_doesnt_match_all() {
2017        let (_tmp, engine) = open_engine();
2018        let mut writer = engine.writer().unwrap();
2019
2020        engine
2021            .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
2022            .unwrap();
2023        engine
2024            .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
2025            .unwrap();
2026        engine.commit(&mut writer).unwrap();
2027
2028        // Wildcard should not match all documents
2029        let results = engine.search("*", 10).unwrap();
2030        // With sanitization, "*" is treated as literal text, not wildcard
2031        // So it should match 0 documents (no content contains literal "*")
2032        assert!(
2033            results.is_empty(),
2034            "wildcard query should not match all documents after sanitization"
2035        );
2036    }
2037
2038    #[test]
2039    fn field_syntax_query_doesnt_access_other_fields() {
2040        let (_tmp, engine) = open_engine();
2041        let mut writer = engine.writer().unwrap();
2042
2043        // Add a doc with "secret" in galaxy field but not content
2044        engine
2045            .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
2046            .unwrap();
2047        engine.commit(&mut writer).unwrap();
2048
2049        // Try to use field syntax to access galaxy field
2050        let results = engine.search("galaxy:secret", 10).unwrap();
2051        // With sanitization, "galaxy:secret" is treated as literal text
2052        // So it should not match the galaxy field
2053        assert!(
2054            results.is_empty(),
2055            "field syntax injection should not access non-searchable fields"
2056        );
2057    }
2058
2059    #[test]
2060    fn boolean_operator_doesnt_bypass_search() {
2061        let (_tmp, engine) = open_engine();
2062        let mut writer = engine.writer().unwrap();
2063
2064        engine
2065            .add_document(
2066                &mut writer,
2067                "uuid-1",
2068                "codex",
2069                "important secret data",
2070                &[],
2071                1000,
2072            )
2073            .unwrap();
2074        engine.commit(&mut writer).unwrap();
2075
2076        // Uppercase operator words are quoted (and stripped as stopwords), so
2077        // they cannot be used to exclude or require terms: "AND secret"
2078        // reduces to a plain search for "secret" and must still match.
2079        let results = engine.search("AND secret", 10).unwrap();
2080        assert_eq!(results.len(), 1);
2081
2082        // A query made only of operators/stopwords has no terms and matches
2083        // nothing — it cannot match the whole corpus.
2084        let results = engine.search("AND OR NOT", 10).unwrap();
2085        assert!(
2086            results.is_empty(),
2087            "operator-only query must not bypass search"
2088        );
2089    }
2090}