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/// Fraction of a query's stopword-stripped terms present in `content`
1041/// (stemming-aware).
1042///
1043/// For callers that need an abstention signal on retrieval paths without a
1044/// `matched_terms` count (the FTS route). Score floors do not separate
1045/// nonsense from real queries on the hosted corpus (measured 2026-09-22:
1046/// nonsense up to 4.55, real from 2.06); coverage does.
1047#[must_use]
1048pub fn token_coverage(content: &str, query: &str) -> f64 {
1049    let stripped = strip_stopwords(query);
1050    let tokens = query_stem_tokens(&stripped);
1051    if tokens.is_empty() {
1052        return 0.0;
1053    }
1054    count_token_hits(content, &stripped) as f64 / tokens.len() as f64
1055}
1056
1057/// Printable-character ratio used by the index/admission gate.
1058///
1059/// Tab, newline, and carriage return are **formatting whitespace, not
1060/// debris** — they count as printable, aligned with the byte-level ingest
1061/// gate (`wm_mcp::ingest::binary_content`), which already treats those
1062/// three bytes as printable. Any other Unicode control character counts
1063/// against the ratio. Empty content returns 1.0 (vacuously clean); callers
1064/// reject emptiness separately.
1065///
1066/// 2026-09-19 benchmark finding: the old definition counted line breaks as
1067/// unprintable, so code/formatting-heavy memories (hex-color lists, HTML
1068/// and jQuery snippets) failed admission even though they are exactly the
1069/// content a coding-agent memory exists to keep.
1070#[must_use]
1071pub fn printable_ratio(content: &str) -> f32 {
1072    let total = content.chars().count();
1073    if total == 0 {
1074        return 1.0;
1075    }
1076    let printable = content
1077        .chars()
1078        .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
1079        .count();
1080    printable as f32 / total as f32
1081}
1082
1083/// Prepare content for indexing.
1084///
1085/// Returns `None` when the content is not clean text and must be skipped:
1086/// - empty / whitespace-only content
1087/// - contains a null byte (binary serialization artifact)
1088/// - printable-char ratio below [`MIN_PRINTABLE_RATIO`] (tab/newline/CR
1089///   count as printable — see [`printable_ratio`])
1090///
1091/// Otherwise returns the content scrubbed of control characters and capped
1092/// at [`MAX_INDEX_CONTENT_LEN`] chars.
1093#[must_use]
1094pub fn sanitize_content_for_index(content: &str) -> Option<String> {
1095    if content.trim().is_empty() {
1096        return None;
1097    }
1098    if content.as_bytes().contains(&0) {
1099        return None;
1100    }
1101    if printable_ratio(content) < MIN_PRINTABLE_RATIO {
1102        return None;
1103    }
1104
1105    let cleaned = scrub_text(content);
1106    let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1107    if capped.trim().is_empty() {
1108        None
1109    } else {
1110        Some(capped)
1111    }
1112}
1113
1114/// Scrub text for output: replace control characters (except newline, tab,
1115/// carriage return) with a space, and cap the length at
1116/// [`MAX_INDEX_CONTENT_LEN`].
1117#[must_use]
1118pub fn scrub_text(content: &str) -> String {
1119    let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1120    for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1121        if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1122            out.push(' ');
1123        } else {
1124            out.push(c);
1125        }
1126    }
1127    out
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133    use tempfile::tempdir;
1134
1135    fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1136        let tmp = tempdir().unwrap();
1137        let engine = SearchEngine::open(tmp.path()).unwrap();
1138        (tmp, engine)
1139    }
1140
1141    /// 2026-09-19 review: limit 0 reached Tantivy's `TopDocs` and panicked
1142    /// the server process (exit 101). The engine must return a caller error
1143    /// before the collector is built — this guard covers every path in.
1144    #[test]
1145    fn search_rejects_zero_limit_without_panicking() {
1146        let (_tmp, engine) = open_engine();
1147        let err = engine.search("anything", 0).unwrap_err();
1148        assert!(err.to_string().contains("limit"), "{err}");
1149        let err = engine
1150            .search_in_galaxy("anything", Some(Galaxy::Codex), 0)
1151            .unwrap_err();
1152        assert!(err.to_string().contains("limit"), "{err}");
1153        let opts = SearchOptions {
1154            limit: 0,
1155            ..SearchOptions::default()
1156        };
1157        assert!(engine.search_opt("anything", &opts).is_err());
1158    }
1159
1160    /// Write a legacy one-field index into `dir`, simulating a store created
1161    /// by an older WhiteMagic version with a different Tantivy schema.
1162    fn write_incompatible_index(dir: &Path) {
1163        std::fs::create_dir_all(dir).unwrap();
1164        let mut builder = Schema::builder();
1165        builder.add_text_field("legacy", STRING | STORED);
1166        let schema = builder.build();
1167        let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1168        Index::open_or_create(directory, schema).unwrap();
1169    }
1170
1171    #[test]
1172    fn open_migrates_incompatible_schema() {
1173        let tmp = tempdir().unwrap();
1174        let dir = tmp.path().join("tantivy");
1175        write_incompatible_index(&dir);
1176
1177        let engine = SearchEngine::open(&dir).unwrap();
1178        assert!(
1179            engine.schema_migrated(),
1180            "incompatible schema must trigger migration"
1181        );
1182
1183        // The old index must be preserved as a .schema-mismatch sibling.
1184        let backups: Vec<_> = std::fs::read_dir(tmp.path())
1185            .unwrap()
1186            .filter_map(std::result::Result::ok)
1187            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1188            .collect();
1189        assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1190
1191        // The fresh index must be writable and searchable.
1192        let mut writer = engine.writer().unwrap();
1193        engine
1194            .add_document(
1195                &mut writer,
1196                "33333333-3333-3333-3333-333333333333",
1197                "codex",
1198                "fresh index after migration",
1199                &[],
1200                1700000000,
1201            )
1202            .unwrap();
1203        engine.commit(&mut writer).unwrap();
1204        let results = engine.search("fresh index", 10).unwrap();
1205        assert_eq!(results.len(), 1);
1206    }
1207
1208    #[test]
1209    fn writer_lock_error_names_path_and_hint() {
1210        // B1: LockBusy used to surface bare ("Failed to acquire Lockfile:
1211        // LockBusy") with no path and no hint — a stray `wm serve` cost a
1212        // debug session to find.
1213        let err = format_writer_lock_error(
1214            "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1215            Path::new("/store/x/tantivy"),
1216        );
1217        assert!(
1218            err.contains("/store/x/tantivy"),
1219            "must name the index path: {err}"
1220        );
1221        assert!(
1222            err.contains("pgrep -af wm"),
1223            "must include the diagnostic hint: {err}"
1224        );
1225        assert!(
1226            err.contains("--readonly"),
1227            "must offer the readonly alternative: {err}"
1228        );
1229
1230        // Non-lock errors pass through unchanged.
1231        let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1232        assert!(other.starts_with("Tantivy writer: disk full"));
1233        assert!(!other.contains("pgrep"));
1234    }
1235
1236    #[test]
1237    fn open_readonly_rejects_incompatible_schema() {
1238        let tmp = tempdir().unwrap();
1239        let dir = tmp.path().join("tantivy");
1240        write_incompatible_index(&dir);
1241
1242        let err = match SearchEngine::open_readonly(&dir) {
1243            Ok(_) => panic!("read-only open must reject an incompatible schema"),
1244            Err(e) => e,
1245        };
1246        assert!(
1247            format!("{err}").contains("wm reindex"),
1248            "read-only mismatch must point at wm reindex, got: {err}"
1249        );
1250
1251        // Nothing was moved: the incompatible index is still in place.
1252        let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1253            .unwrap()
1254            .filter_map(std::result::Result::ok)
1255            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1256            .collect();
1257        assert!(siblings.is_empty(), "read-only open must not migrate");
1258    }
1259
1260    #[test]
1261    fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1262        let tmp = tempdir().unwrap();
1263        let dir = tmp.path().join("tantivy");
1264        std::fs::create_dir_all(&dir).unwrap();
1265
1266        let err = match SearchEngine::open_readonly(&dir) {
1267            Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1268            Err(err) => err,
1269        };
1270        assert!(format!("{err}").contains("readonly open-existing"));
1271        assert!(
1272            std::fs::read_dir(&dir).unwrap().next().is_none(),
1273            "readonly open must not materialize Tantivy metadata or segments"
1274        );
1275    }
1276
1277    #[test]
1278    fn reopen_matching_schema_not_migrated() {
1279        let tmp = tempdir().unwrap();
1280        let dir = tmp.path().join("tantivy");
1281        std::fs::create_dir_all(&dir).unwrap();
1282
1283        let first = SearchEngine::open(&dir).unwrap();
1284        assert!(!first.schema_migrated());
1285        drop(first); // release the tantivy writer lock before reopening
1286
1287        let second = SearchEngine::open(&dir).unwrap();
1288        assert!(
1289            !second.schema_migrated(),
1290            "matching schema must not migrate"
1291        );
1292        drop(second);
1293
1294        let third = SearchEngine::open_readonly(&dir).unwrap();
1295        assert!(!third.schema_migrated());
1296    }
1297
1298    #[test]
1299    fn index_and_search_basic() {
1300        let (_tmp, engine) = open_engine();
1301        let mut writer = engine.writer().unwrap();
1302
1303        engine
1304            .add_document(
1305                &mut writer,
1306                "11111111-1111-1111-1111-111111111111",
1307                "codex",
1308                "The Rust programming language is fast and safe",
1309                &["rust".into(), "programming".into()],
1310                1700000000,
1311            )
1312            .unwrap();
1313        engine
1314            .add_document(
1315                &mut writer,
1316                "22222222-2222-2222-2222-222222222222",
1317                "codex",
1318                "Python is great for data science",
1319                &["python".into(), "data".into()],
1320                1700000001,
1321            )
1322            .unwrap();
1323        engine.commit(&mut writer).unwrap();
1324
1325        let results = engine.search("rust", 10).unwrap();
1326        assert!(!results.is_empty());
1327        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1328    }
1329
1330    #[test]
1331    fn search_by_tag() {
1332        let (_tmp, engine) = open_engine();
1333        let mut writer = engine.writer().unwrap();
1334
1335        engine
1336            .add_document(
1337                &mut writer,
1338                "11111111-1111-1111-1111-111111111111",
1339                "codex",
1340                "memory about systems",
1341                &["rust".into()],
1342                1700000000,
1343            )
1344            .unwrap();
1345        engine
1346            .add_document(
1347                &mut writer,
1348                "22222222-2222-2222-2222-222222222222",
1349                "codex",
1350                "memory about cooking",
1351                &["food".into()],
1352                1700000001,
1353            )
1354            .unwrap();
1355        engine.commit(&mut writer).unwrap();
1356
1357        let results = engine.search("rust", 10).unwrap();
1358        assert_eq!(results.len(), 1);
1359        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1360    }
1361
1362    #[test]
1363    fn search_filtered_by_galaxy() {
1364        let (_tmp, engine) = open_engine();
1365        let mut writer = engine.writer().unwrap();
1366
1367        engine
1368            .add_document(
1369                &mut writer,
1370                "11111111-1111-1111-1111-111111111111",
1371                "codex",
1372                "important knowledge",
1373                &[],
1374                1700000000,
1375            )
1376            .unwrap();
1377        engine
1378            .add_document(
1379                &mut writer,
1380                "22222222-2222-2222-2222-222222222222",
1381                "research",
1382                "important findings",
1383                &[],
1384                1700000001,
1385            )
1386            .unwrap();
1387        engine.commit(&mut writer).unwrap();
1388
1389        let results = engine
1390            .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1391            .unwrap();
1392        assert_eq!(results.len(), 1);
1393        assert_eq!(results[0].galaxy, "codex");
1394    }
1395
1396    #[test]
1397    fn delete_document_from_index() {
1398        let (_tmp, engine) = open_engine();
1399        let mut writer = engine.writer().unwrap();
1400
1401        engine
1402            .add_document(
1403                &mut writer,
1404                "11111111-1111-1111-1111-111111111111",
1405                "codex",
1406                "deletable content",
1407                &[],
1408                1700000000,
1409            )
1410            .unwrap();
1411        engine.commit(&mut writer).unwrap();
1412
1413        let results = engine.search("deletable", 10).unwrap();
1414        assert_eq!(results.len(), 1);
1415
1416        engine
1417            .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1418            .unwrap();
1419        engine.commit(&mut writer).unwrap();
1420
1421        let results = engine.search("deletable", 10).unwrap();
1422        assert_eq!(results.len(), 0);
1423    }
1424
1425    #[test]
1426    fn search_empty_index() {
1427        let (_tmp, engine) = open_engine();
1428        let results = engine.search("anything", 10).unwrap();
1429        assert!(results.is_empty());
1430    }
1431
1432    #[test]
1433    fn search_ids_returns_uuids() {
1434        let (_tmp, engine) = open_engine();
1435        let mut writer = engine.writer().unwrap();
1436
1437        engine
1438            .add_document(
1439                &mut writer,
1440                "11111111-1111-1111-1111-111111111111",
1441                "codex",
1442                "unique content about rust",
1443                &[],
1444                1700000000,
1445            )
1446            .unwrap();
1447        engine.commit(&mut writer).unwrap();
1448
1449        let ids = engine.search_ids("rust", 10).unwrap();
1450        assert_eq!(ids.len(), 1);
1451        assert_eq!(
1452            ids[0],
1453            uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1454        );
1455    }
1456
1457    // ── Tantivy query injection tests ───────────────────────────────
1458
1459    #[test]
1460    fn sanitize_leaves_plain_terms_unquoted() {
1461        let result = sanitize_tantivy_query("hello world");
1462        assert_eq!(result, "hello world");
1463    }
1464
1465    #[test]
1466    fn sanitize_drops_punct_only_terms() {
1467        let result = sanitize_tantivy_query("*");
1468        assert_eq!(result, "");
1469        // Should not match all documents when parsed
1470    }
1471
1472    #[test]
1473    fn sanitize_escapes_boolean_operators() {
1474        let result = sanitize_tantivy_query("NOT secret");
1475        assert_eq!(result, "\"NOT\" secret");
1476    }
1477
1478    #[test]
1479    fn sanitize_escapes_field_syntax() {
1480        let result = sanitize_tantivy_query("content:secret");
1481        assert_eq!(result, "\"content:secret\"");
1482    }
1483
1484    #[test]
1485    fn sanitize_escapes_quotes() {
1486        let result = sanitize_tantivy_query("test\"injection");
1487        assert!(
1488            result.contains("\\\""),
1489            "embedded quotes should be escaped: {result}"
1490        );
1491    }
1492
1493    #[test]
1494    fn sanitize_escapes_trailing_backslash_token() {
1495        // A term ending in a backslash used to become "abc\" — the dangling
1496        // escape swallowed the closing quote and failed the query parser.
1497        let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1498        assert_eq!(
1499            result, "\"C:\\\\Users\\\\temp\\\\\"",
1500            "backslashes must be doubled inside quoted terms"
1501        );
1502    }
1503
1504    #[test]
1505    fn lenient_fallback_never_fails_on_malformed_input() {
1506        let (_tmp, engine) = open_engine();
1507        let parser =
1508            QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1509        let searcher = engine.reader.searcher();
1510        let collector = TopDocs::with_limit(1).order_by_score();
1511        for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1512            let parsed = parse_query_with_fallback(&parser, malformed);
1513            searcher
1514                .search(&parsed, &collector)
1515                .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1516        }
1517    }
1518
1519    #[test]
1520    fn sanitize_empty_returns_empty() {
1521        assert_eq!(sanitize_tantivy_query(""), "");
1522        assert_eq!(sanitize_tantivy_query("   "), "");
1523    }
1524
1525    #[test]
1526    fn sanitize_preserves_alphanumeric() {
1527        let result = sanitize_tantivy_query("rust programming 2024");
1528        assert_eq!(result, "rust programming 2024");
1529    }
1530
1531    #[test]
1532    fn sanitize_preserves_hyphenated_compounds() {
1533        let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1534        assert_eq!(result, "antigravity antigravity-project-test");
1535    }
1536
1537    // ── Stopword tests ──────────────────────────────────────────────
1538
1539    #[test]
1540    fn strip_stopwords_removes_common_words() {
1541        assert_eq!(
1542            strip_stopwords("smoke test from wmClient"),
1543            "smoke test wmClient"
1544        );
1545        assert_eq!(strip_stopwords("the from and or"), "");
1546        assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1547        assert_eq!(strip_stopwords(""), "");
1548    }
1549
1550    #[test]
1551    fn strip_stopwords_is_case_insensitive() {
1552        assert_eq!(strip_stopwords("FROM The And"), "");
1553    }
1554
1555    // ── Index-time sanitization tests ───────────────────────────────
1556
1557    #[test]
1558    fn sanitize_content_skips_null_bytes() {
1559        let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1560        assert!(sanitize_content_for_index(content).is_none());
1561    }
1562
1563    #[test]
1564    fn sanitize_content_skips_low_printable_ratio() {
1565        // 5 control chars out of 11 → ratio 0.55 < 0.9 → skip
1566        let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1567        assert!(sanitize_content_for_index(content).is_none());
1568    }
1569
1570    #[test]
1571    fn sanitize_content_accepts_code_and_formatting_heavy_text() {
1572        // 2026-09-19 benchmark regression: code/formatting-heavy turns were
1573        // rejected because every line break counted as unprintable. Tab,
1574        // newline, and CR are formatting whitespace — they must not fail
1575        // admission (the byte-level ingest gate already treats them as
1576        // printable bytes).
1577        let hex_list = "Casper\n#ACBFCD\n\nPickled Bluewood\n#324558\n\nComet\n#545B70\n";
1578        assert!(sanitize_content_for_index(hex_list).is_some());
1579        let html = "Sure, here's how:\n```html\n<!DOCTYPE html>\n<html>\n  <body>\n \n \n  </body>\n</html>\n```";
1580        assert!(sanitize_content_for_index(html).is_some());
1581        // Genuine binary control chars still fail the ratio.
1582        let binary = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1583        assert!(sanitize_content_for_index(binary).is_none());
1584    }
1585
1586    #[test]
1587    fn printable_ratio_treats_line_breaks_as_printable() {
1588        assert!((printable_ratio("a\nb\tc\rd") - 1.0).abs() < f32::EPSILON);
1589        assert!(printable_ratio("\u{01}\u{02}\u{03}\u{04}\u{05}hello") < 0.9);
1590        assert!((printable_ratio("") - 1.0).abs() < f32::EPSILON);
1591    }
1592
1593    #[test]
1594    fn sanitize_content_scrubs_and_caps() {
1595        // A stray control char does not disqualify clean text — it is scrubbed.
1596        let content = "clean text\u{01}with one control char";
1597        let cleaned = sanitize_content_for_index(content).unwrap();
1598        assert!(!cleaned.contains('\u{01}'));
1599        assert!(cleaned.starts_with("clean text with one control char"));
1600
1601        let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1602        let capped = sanitize_content_for_index(&long).unwrap();
1603        assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1604    }
1605
1606    #[test]
1607    fn sanitize_content_skips_empty() {
1608        assert!(sanitize_content_for_index("").is_none());
1609        assert!(sanitize_content_for_index("   ").is_none());
1610    }
1611
1612    #[test]
1613    fn scrub_text_replaces_control_chars() {
1614        let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1615        assert_eq!(result, "a b\nc\td e");
1616    }
1617
1618    #[test]
1619    fn add_document_skips_binary_content() {
1620        let (_tmp, engine) = open_engine();
1621        let mut writer = engine.writer().unwrap();
1622
1623        engine
1624            .add_document(
1625                &mut writer,
1626                "11111111-1111-1111-1111-111111111111",
1627                "codex",
1628                "\u{00}\u{01}\u{02}raw serialized bytes",
1629                &[],
1630                1700000000,
1631            )
1632            .unwrap();
1633        engine
1634            .add_document(
1635                &mut writer,
1636                "22222222-2222-2222-2222-222222222222",
1637                "codex",
1638                "clean searchable text",
1639                &[],
1640                1700000001,
1641            )
1642            .unwrap();
1643        engine.commit(&mut writer).unwrap();
1644
1645        // The binary doc must not be searchable; the clean one must be.
1646        let results = engine.search("serialized", 10).unwrap();
1647        assert!(results.is_empty(), "binary content must not be indexed");
1648
1649        let results = engine.search("clean", 10).unwrap();
1650        assert_eq!(results.len(), 1);
1651        assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1652    }
1653
1654    // ── Score-threshold tests ───────────────────────────────────────
1655
1656    fn index_alpha_pair(engine: &SearchEngine) {
1657        let mut writer = engine.writer().unwrap();
1658        // Two docs both containing "alpha"; the short one scores higher
1659        // (BM25 length normalization).
1660        engine
1661            .add_document(
1662                &mut writer,
1663                "11111111-1111-1111-1111-111111111111",
1664                "codex",
1665                "alpha",
1666                &[],
1667                1700000000,
1668            )
1669            .unwrap();
1670        let filler = format!("alpha {}", "zzz ".repeat(400));
1671        engine
1672            .add_document(
1673                &mut writer,
1674                "22222222-2222-2222-2222-222222222222",
1675                "codex",
1676                &filler,
1677                &[],
1678                1700000001,
1679            )
1680            .unwrap();
1681        engine.commit(&mut writer).unwrap();
1682    }
1683
1684    #[test]
1685    fn strip_stopwords_ignores_trailing_punctuation() {
1686        assert_eq!(
1687            strip_stopwords("how many capabilities are there?"),
1688            "many capabilities"
1689        );
1690    }
1691
1692    /// Regression: a natural-language question whose words are mostly absent
1693    /// from the corpus returned zero results. The trailing "?" kept "there?"
1694    /// from being recognized as a stopword, which pushed the query over the
1695    /// token-coverage floor; the floor now counts only index-present tokens.
1696    #[test]
1697    fn question_with_absent_token_still_matches() {
1698        let (_tmp, engine) = open_engine();
1699        let mut writer = engine.writer().unwrap();
1700        engine
1701            .add_document(
1702                &mut writer,
1703                "33333333-3333-3333-3333-333333333333",
1704                "codex",
1705                "unique zebra content",
1706                &[],
1707                1700000002,
1708            )
1709            .unwrap();
1710        engine.commit(&mut writer).unwrap();
1711
1712        // "many", "stripes", and "exist" are absent from the corpus; the old
1713        // all-query-token floor (2 of 4) filtered the only valid hit.
1714        let results = engine.search("how many zebra stripes exist?", 5).unwrap();
1715        assert_eq!(
1716            results.len(),
1717            1,
1718            "the single valid hit must survive the coverage floor"
1719        );
1720        assert_eq!(results[0].memory_id, "33333333-3333-3333-3333-333333333333");
1721    }
1722
1723    #[test]
1724    fn search_absolute_min_score_filters_weak_matches() {
1725        let (_tmp, engine) = open_engine();
1726        index_alpha_pair(&engine);
1727
1728        let base = engine.search("alpha", 10).unwrap();
1729        assert_eq!(base.len(), 2);
1730        let (hi, lo) = if base[0].score >= base[1].score {
1731            (base[0].score, base[1].score)
1732        } else {
1733            (base[1].score, base[0].score)
1734        };
1735        assert!(
1736            hi > lo,
1737            "short doc should outscore long doc (hi={hi}, lo={lo})"
1738        );
1739        let mid = f32::midpoint(hi, lo);
1740
1741        let opts = SearchOptions {
1742            limit: 10,
1743            min_score: Some(mid),
1744            ..SearchOptions::default()
1745        };
1746        let filtered = engine.search_opt("alpha", &opts).unwrap();
1747        assert_eq!(filtered.len(), 1);
1748        assert!((filtered[0].score - hi).abs() < 1e-3);
1749    }
1750
1751    #[test]
1752    fn search_relative_floor_filters_weak_matches() {
1753        let (_tmp, engine) = open_engine();
1754        index_alpha_pair(&engine);
1755
1756        let opts = SearchOptions {
1757            limit: 10,
1758            relative_floor: Some(0.5),
1759            ..SearchOptions::default()
1760        };
1761        let filtered = engine.search_opt("alpha", &opts).unwrap();
1762        assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1763        assert_eq!(
1764            filtered[0].memory_id,
1765            "11111111-1111-1111-1111-111111111111"
1766        );
1767        assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1768    }
1769
1770    #[test]
1771    fn search_all_results_normalized() {
1772        let (_tmp, engine) = open_engine();
1773        index_alpha_pair(&engine);
1774
1775        let results = engine.search("alpha", 10).unwrap();
1776        assert_eq!(results.len(), 2);
1777        assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1778        for r in &results[1..] {
1779            assert!(r.normalized_score <= 1.0);
1780            assert!(r.normalized_score > 0.0);
1781        }
1782    }
1783
1784    #[test]
1785    fn search_stemming_matches_morphological_variants() {
1786        // The en_stem tokenizer should match morphological variants:
1787        // "graduate" (query) ↔ "graduated" (indexed content).
1788        let (_tmp, engine) = open_engine();
1789        let mut writer = engine.writer().unwrap();
1790
1791        engine
1792            .add_document(
1793                &mut writer,
1794                "11111111-1111-1111-1111-111111111111",
1795                "codex",
1796                "I graduated with a degree in Business Administration",
1797                &[],
1798                1700000000,
1799            )
1800            .unwrap();
1801        engine.commit(&mut writer).unwrap();
1802
1803        // Query with present tense "graduate" should match past tense "graduated"
1804        let results = engine.search("graduate", 10).unwrap();
1805        assert_eq!(results.len(), 1);
1806        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1807
1808        // Query with "degrees" should match "degree"
1809        let results = engine.search("degrees", 10).unwrap();
1810        assert_eq!(results.len(), 1);
1811    }
1812
1813    #[test]
1814    fn search_incident_query_returns_only_relevant() {
1815        // Mirrors the 2026-08-11 incident: `memory.hybrid_recall`
1816        // query "smoke test from wmClient" must NOT return unrelated memories.
1817        let (_tmp, engine) = open_engine();
1818        let mut writer = engine.writer().unwrap();
1819
1820        let smoke_id = "11111111-1111-1111-1111-111111111111";
1821        engine
1822            .add_document(
1823                &mut writer,
1824                smoke_id,
1825                "codex",
1826                "smoke test from wmClient: verify recall works",
1827                &[],
1828                1700000000,
1829            )
1830            .unwrap();
1831        let unrelated = [
1832            "NES Evolution and Impact: a history of the console wars",
1833            "Insights on The Gateless Gate: koans and zen practice",
1834            "What the tweet is really saying: a thread analysis",
1835        ];
1836        for (i, content) in (1i64..).zip(unrelated.iter()) {
1837            engine
1838                .add_document(
1839                    &mut writer,
1840                    &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1841                    "codex",
1842                    content,
1843                    &[],
1844                    1700000000 + i,
1845                )
1846                .unwrap();
1847        }
1848        engine.commit(&mut writer).unwrap();
1849
1850        let results = engine.search("smoke test from wmClient", 20).unwrap();
1851        assert_eq!(
1852            results.len(),
1853            1,
1854            "only the smoke memory should match: {results:?}"
1855        );
1856        assert_eq!(results[0].memory_id, smoke_id);
1857        assert!(results[0].content.contains("smoke test"));
1858    }
1859
1860    #[test]
1861    fn search_project_compound_query() {
1862        // "antigravity antigravity-project-test" must find the project memory.
1863        let (_tmp, engine) = open_engine();
1864        let mut writer = engine.writer().unwrap();
1865
1866        engine
1867            .add_document(
1868                &mut writer,
1869                "11111111-1111-1111-1111-111111111111",
1870                "codex",
1871                "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1872                &["project_antigravity-project-test".into()],
1873                1700000000,
1874            )
1875            .unwrap();
1876        engine.commit(&mut writer).unwrap();
1877
1878        let results = engine
1879            .search("antigravity antigravity-project-test", 10)
1880            .unwrap();
1881        assert!(
1882            !results.is_empty(),
1883            "project compound query must match the antigravity memory"
1884        );
1885        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1886    }
1887
1888    #[test]
1889    fn or_default_filters_partial_matches_via_coverage() {
1890        let (_tmp, engine) = open_engine();
1891        let mut writer = engine.writer().unwrap();
1892
1893        engine
1894            .add_document(
1895                &mut writer,
1896                "11111111-1111-1111-1111-111111111111",
1897                "codex",
1898                "alpha beta gamma delta",
1899                &[],
1900                1700000000,
1901            )
1902            .unwrap();
1903        engine
1904            .add_document(
1905                &mut writer,
1906                "22222222-2222-2222-2222-222222222222",
1907                "codex",
1908                "alpha only here",
1909                &[],
1910                1700000001,
1911            )
1912            .unwrap();
1913        engine.commit(&mut writer).unwrap();
1914
1915        // OR is now the default.  A 3-term query requires 2/3 token coverage,
1916        // so the "alpha only here" doc (1/3) is filtered out.
1917        let results = engine.search("alpha beta gamma", 10).unwrap();
1918        assert_eq!(results.len(), 1);
1919        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1920
1921        // With a 2-term query the floor is 1/2, so partial matches return.
1922        let results = engine.search("alpha beta", 10).unwrap();
1923        assert_eq!(results.len(), 2);
1924    }
1925
1926    #[test]
1927    fn coverage_is_case_insensitive() {
1928        let (_tmp, engine) = open_engine();
1929        let mut writer = engine.writer().unwrap();
1930        engine
1931            .add_document(
1932                &mut writer,
1933                "11111111-1111-1111-1111-111111111111",
1934                "codex",
1935                "Smoke Test for wmClient integration",
1936                &[],
1937                1700000000,
1938            )
1939            .unwrap();
1940        engine
1941            .add_document(
1942                &mut writer,
1943                "22222222-2222-2222-2222-222222222222",
1944                "codex",
1945                "test only here",
1946                &[],
1947                1700000001,
1948            )
1949            .unwrap();
1950        engine.commit(&mut writer).unwrap();
1951
1952        // 3 tokens: smoke + test + wmclient (case-insensitive stemming-aware
1953        // whole-word matching) — only the first doc covers 2/3; the "test
1954        // only" doc covers 1/3 and must be dropped even though it matched
1955        // via OR.
1956        let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1957        assert_eq!(results.len(), 1);
1958        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1959    }
1960
1961    #[test]
1962    fn coverage_matches_stemmed_variants() {
1963        // Stemming-aware coverage: "graduate" should match "graduated",
1964        // "classes" should match "class", mirroring the en_stem tokenizer.
1965        let (_tmp, engine) = open_engine();
1966        let mut writer = engine.writer().unwrap();
1967        engine
1968            .add_document(
1969                &mut writer,
1970                "11111111-1111-1111-1111-111111111111",
1971                "codex",
1972                "I graduated with a degree in Business Administration",
1973                &[],
1974                1700000000,
1975            )
1976            .unwrap();
1977        engine
1978            .add_document(
1979                &mut writer,
1980                "22222222-2222-2222-2222-222222222222",
1981                "codex",
1982                "degree only here",
1983                &[],
1984                1700000001,
1985            )
1986            .unwrap();
1987        engine.commit(&mut writer).unwrap();
1988
1989        // 2-term query "graduate degree" → coverage floor 1/2.
1990        // Doc 1: "graduated" stems to "graduat", "degree" stems to "degre" → 2/2.
1991        // Doc 2: "degree" → 1/2.
1992        // Both pass the 1/2 floor, but doc 1 should rank higher due to
1993        // the coverage-ratio boost (2/2 > 1/2).
1994        let results = engine.search("graduate degree", 10).unwrap();
1995        assert_eq!(results.len(), 2);
1996        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1997    }
1998
1999    #[test]
2000    fn token_coverage_counts_stemmed_whole_words() {
2001        // Direct unit coverage for the FTS-route abstention signal.
2002        let content = "Carried the stories for classes in Business Administration";
2003        assert_eq!(
2004            token_coverage(content, "carry stories classes business"),
2005            1.0
2006        );
2007        assert_eq!(
2008            token_coverage(content, "carry stories classes certificate"),
2009            0.75
2010        );
2011        // Stemming-aware: query singular matches the plural in content.
2012        // ("class" → "clas" but "classes" → "class": the conservative
2013        // stemmer is intentionally asymmetric; use a clean -ies case.)
2014        assert_eq!(token_coverage(content, "story"), 1.0);
2015        assert_eq!(token_coverage(content, "unrelated nonsense"), 0.0);
2016        // Stopword-only and empty queries carry no signal.
2017        assert_eq!(token_coverage(content, "the and of"), 0.0);
2018        assert_eq!(token_coverage(content, ""), 0.0);
2019        // Case-insensitive, like the index.
2020        assert_eq!(token_coverage(content, "BUSINESS administration"), 1.0);
2021    }
2022
2023    #[test]
2024    fn coverage_normalizes_possessives_and_punctuation() {
2025        let query = "buy sister's birthday gift";
2026        assert_eq!(
2027            query_stem_tokens(query),
2028            ["buy", "sister", "birthday", "gift"]
2029        );
2030        assert_eq!(
2031            count_token_hits("I bought a dress for my sister birthday", query),
2032            2
2033        );
2034    }
2035
2036    #[test]
2037    fn search_stopword_only_query_returns_nothing() {
2038        let (_tmp, engine) = open_engine();
2039        let mut writer = engine.writer().unwrap();
2040        engine
2041            .add_document(
2042                &mut writer,
2043                "11111111-1111-1111-1111-111111111111",
2044                "codex",
2045                "some ordinary text",
2046                &[],
2047                1700000000,
2048            )
2049            .unwrap();
2050        engine.commit(&mut writer).unwrap();
2051
2052        let results = engine.search("the from and or", 10).unwrap();
2053        assert!(results.is_empty());
2054    }
2055
2056    #[test]
2057    fn wildcard_query_doesnt_match_all() {
2058        let (_tmp, engine) = open_engine();
2059        let mut writer = engine.writer().unwrap();
2060
2061        engine
2062            .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
2063            .unwrap();
2064        engine
2065            .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
2066            .unwrap();
2067        engine.commit(&mut writer).unwrap();
2068
2069        // Wildcard should not match all documents
2070        let results = engine.search("*", 10).unwrap();
2071        // With sanitization, "*" is treated as literal text, not wildcard
2072        // So it should match 0 documents (no content contains literal "*")
2073        assert!(
2074            results.is_empty(),
2075            "wildcard query should not match all documents after sanitization"
2076        );
2077    }
2078
2079    #[test]
2080    fn field_syntax_query_doesnt_access_other_fields() {
2081        let (_tmp, engine) = open_engine();
2082        let mut writer = engine.writer().unwrap();
2083
2084        // Add a doc with "secret" in galaxy field but not content
2085        engine
2086            .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
2087            .unwrap();
2088        engine.commit(&mut writer).unwrap();
2089
2090        // Try to use field syntax to access galaxy field
2091        let results = engine.search("galaxy:secret", 10).unwrap();
2092        // With sanitization, "galaxy:secret" is treated as literal text
2093        // So it should not match the galaxy field
2094        assert!(
2095            results.is_empty(),
2096            "field syntax injection should not access non-searchable fields"
2097        );
2098    }
2099
2100    #[test]
2101    fn boolean_operator_doesnt_bypass_search() {
2102        let (_tmp, engine) = open_engine();
2103        let mut writer = engine.writer().unwrap();
2104
2105        engine
2106            .add_document(
2107                &mut writer,
2108                "uuid-1",
2109                "codex",
2110                "important secret data",
2111                &[],
2112                1000,
2113            )
2114            .unwrap();
2115        engine.commit(&mut writer).unwrap();
2116
2117        // Uppercase operator words are quoted (and stripped as stopwords), so
2118        // they cannot be used to exclude or require terms: "AND secret"
2119        // reduces to a plain search for "secret" and must still match.
2120        let results = engine.search("AND secret", 10).unwrap();
2121        assert_eq!(results.len(), 1);
2122
2123        // A query made only of operators/stopwords has no terms and matches
2124        // nothing — it cannot match the whole corpus.
2125        let results = engine.search("AND OR NOT", 10).unwrap();
2126        assert!(
2127            results.is_empty(),
2128            "operator-only query must not bypass search"
2129        );
2130    }
2131}