1use 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,
24 collector::TopDocs,
25 doc,
26 query::QueryParser,
27 schema::{
28 Field, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, TextOptions, Value,
29 },
30};
31
32pub const MAX_INDEX_CONTENT_LEN: usize = 8 * 1024;
34
35pub const MIN_PRINTABLE_RATIO: f32 = 0.9;
37
38pub 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
169pub struct SearchOptions {
170 pub limit: usize,
172 pub galaxy: Option<Galaxy>,
174 pub min_score: Option<f32>,
176 pub relative_floor: Option<f32>,
179 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SearchResult {
200 pub memory_id: String,
202 pub galaxy: String,
204 pub score: f32,
206 pub normalized_score: f32,
208 pub content: String,
210}
211
212#[derive(Debug, Default)]
219pub struct IndexHealth {
220 pub successes: AtomicU64,
222 pub failures: AtomicU64,
224 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 #[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
260fn format_writer_lock_error(err: &str, index_path: &Path) -> String {
264 let is_lock = err.contains("ock") && (err.contains("Busy") || err.contains("lock"));
265 if is_lock {
266 format!(
267 "Tantivy writer: {err} — the search index at {} is locked by another process. \
268 A running `wm serve` or `wm daemon` on this store holds it; find it with \
269 `pgrep -af wm` and stop it, or start this server with --readonly.",
270 index_path.display()
271 )
272 } else {
273 format!("Tantivy writer: {err}")
274 }
275}
276
277pub struct SearchEngine {
279 index: Index,
280 reader: IndexReader,
281 writer: Mutex<Option<IndexWriter>>,
282 field_id: Field,
283 field_galaxy: Field,
284 field_content: Field,
285 field_tags: Field,
286 field_timestamp: Field,
287 health: IndexHealth,
290 schema_migrated: bool,
296}
297
298impl SearchEngine {
299 fn build_schema() -> (Schema, Field, Field, Field, Field, Field) {
301 let mut schema_builder = Schema::builder();
302 let field_id = schema_builder.add_text_field("memory_id", STRING | STORED);
303 let field_galaxy = schema_builder.add_text_field("galaxy", STRING | STORED);
304 let stem_indexing = TextFieldIndexing::default()
307 .set_tokenizer("en_stem")
308 .set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions);
309 let stem_text = TextOptions::default()
310 .set_indexing_options(stem_indexing.clone())
311 .set_stored();
312 let stem_tags = TextOptions::default().set_indexing_options(stem_indexing);
313 let field_content = schema_builder.add_text_field("content", stem_text);
314 let field_tags = schema_builder.add_text_field("tags", stem_tags);
315 let field_timestamp = schema_builder.add_i64_field("timestamp", STORED);
316 let schema = schema_builder.build();
317 (
318 schema,
319 field_id,
320 field_galaxy,
321 field_content,
322 field_tags,
323 field_timestamp,
324 )
325 }
326
327 fn open_index(path: &Path, schema: &Schema, writable: bool) -> Result<(Index, bool)> {
338 let directory = tantivy::directory::MmapDirectory::open(path)
339 .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
340 match Index::open_or_create(directory, schema.clone()) {
341 Ok(index) => Ok((index, false)),
342 Err(tantivy::error::TantivyError::SchemaError(_)) => {
343 if !writable {
344 return Err(CoreError::Memory(format!(
345 "Tantivy index at {} was created with an incompatible schema by an \
346 older version. Run 'wm reindex' (or start 'wm serve' without \
347 --readonly) to migrate and rebuild it from the canonical store.",
348 path.display()
349 )));
350 }
351 let ts = std::time::SystemTime::now()
352 .duration_since(std::time::UNIX_EPOCH)
353 .map_or(0, |d| d.as_millis());
354 let file_name = path
355 .file_name()
356 .and_then(|n| n.to_str())
357 .unwrap_or("tantivy");
358 let backup = path.with_file_name(format!("{file_name}.schema-mismatch.{ts}"));
359 std::fs::rename(path, &backup).map_err(|e| {
360 CoreError::Memory(format!(
361 "Tantivy schema migration — rename old index to {}: {e}",
362 backup.display()
363 ))
364 })?;
365 std::fs::create_dir_all(path).map_err(|e| {
366 CoreError::Memory(format!("Tantivy schema migration — create index dir: {e}"))
367 })?;
368 tracing::warn!(
369 "Tantivy index schema mismatch — old index moved to {}; creating a fresh \
370 index (rebuild from LMDB will follow)",
371 backup.display()
372 );
373 let directory = tantivy::directory::MmapDirectory::open(path)
374 .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
375 let index = Index::open_or_create(directory, schema.clone())
376 .map_err(|e| CoreError::Memory(format!("Tantivy open_or_create: {e}")))?;
377 Ok((index, true))
378 }
379 Err(e) => Err(CoreError::Memory(format!("Tantivy open_or_create: {e}"))),
380 }
381 }
382
383 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
385 let path = path.as_ref();
386 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
387 Self::build_schema();
388
389 let (index, schema_migrated) = Self::open_index(path, &schema, true)?;
390
391 let reader = index
392 .reader_builder()
393 .reload_policy(ReloadPolicy::OnCommitWithDelay)
394 .try_into()
395 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
396
397 let writer = index
398 .writer(50_000_000)
399 .map_err(|e| CoreError::Memory(format_writer_lock_error(&e.to_string(), path)))?;
400
401 Ok(Self {
402 index,
403 reader,
404 writer: Mutex::new(Some(writer)),
405 field_id,
406 field_galaxy,
407 field_content,
408 field_tags,
409 field_timestamp,
410 health: IndexHealth::default(),
411 schema_migrated,
412 })
413 }
414
415 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
420 let path = path.as_ref();
421 tracing::warn!(
424 "read-only search index opened at {} — it will not observe writes made \
425 after this point; restart the read-only server to pick up new memories",
426 path.display()
427 );
428 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
429 Self::build_schema();
430 let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
431 let reader = index
432 .reader_builder()
433 .reload_policy(ReloadPolicy::OnCommitWithDelay)
434 .try_into()
435 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
436 Ok(Self {
437 index,
438 reader,
439 writer: Mutex::new(None),
440 field_id,
441 field_galaxy,
442 field_content,
443 field_tags,
444 field_timestamp,
445 health: IndexHealth::default(),
446 schema_migrated,
447 })
448 }
449
450 #[must_use]
456 pub const fn schema_migrated(&self) -> bool {
457 self.schema_migrated
458 }
459
460 #[must_use]
463 pub const fn health(&self) -> &IndexHealth {
464 &self.health
465 }
466
467 pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
473 self.reader
477 .reload()
478 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
479 let searcher = self.reader.searcher();
480 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
481 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
482 let count = searcher
483 .search(&query, &tantivy::collector::Count)
484 .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
485 Ok(count)
486 }
487
488 pub fn indexed_ids_in_galaxy(&self, galaxy: &str) -> Result<std::collections::HashSet<String>> {
495 self.reader
499 .reload()
500 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
501 let searcher = self.reader.searcher();
502 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
503 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
504 let count = self.count_docs_in_galaxy(galaxy)?;
505 let hits: std::collections::HashSet<tantivy::DocAddress> = searcher
506 .search(&query, &tantivy::collector::DocSetCollector)
507 .map_err(|e| CoreError::Memory(format!("Tantivy indexed_ids: {e}")))?;
508 let mut out = std::collections::HashSet::with_capacity(count);
509 for addr in hits {
510 let doc: TantivyDocument = searcher
511 .doc(addr)
512 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
513 if let Some(id) = doc.get_first(self.field_id).and_then(|v| v.as_str()) {
514 out.insert(id.to_string());
515 }
516 }
517 Ok(out)
518 }
519
520 pub fn is_readonly(&self) -> bool {
522 self.writer.lock().map_or(true, |g| g.is_none())
523 }
524
525 pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
531 let guard = self
532 .writer
533 .lock()
534 .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
535 if guard.is_none() {
536 return Err(CoreError::Memory(
537 "Tantivy writer unavailable: index opened read-only".into(),
538 ));
539 }
540 Ok(guard)
541 }
542
543 pub fn add_document(
550 &self,
551 writer: &mut Option<IndexWriter>,
552 memory_id: &str,
553 galaxy: &str,
554 content: &str,
555 tags: &[String],
556 timestamp: i64,
557 ) -> Result<()> {
558 let writer = writer.as_mut().ok_or_else(|| {
559 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
560 })?;
561 let Some(clean_content) = sanitize_content_for_index(content) else {
562 tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
563 return Ok(());
564 };
565 let tags_str = tags.join(" ");
566 let doc = doc!(
567 self.field_id => memory_id,
568 self.field_galaxy => galaxy,
569 self.field_content => clean_content,
570 self.field_tags => tags_str,
571 self.field_timestamp => timestamp,
572 );
573 match writer.add_document(doc) {
574 Ok(_) => {
575 self.health.record_success();
576 Ok(())
577 }
578 Err(e) => {
579 let msg = format!("Tantivy add_document: {e}");
580 self.health.record_failure(&msg);
581 Err(CoreError::Memory(msg))
582 }
583 }
584 }
585
586 pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
588 let writer = writer.as_mut().ok_or_else(|| {
589 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
590 })?;
591 let term = tantivy::Term::from_field_text(self.field_id, memory_id);
592 writer.delete_term(term);
593 Ok(())
594 }
595
596 pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
601 let writer = writer.as_mut().ok_or_else(|| {
602 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
603 })?;
604 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
605 writer.delete_term(term);
606 Ok(())
607 }
608
609 pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
611 let writer = writer.as_mut().ok_or_else(|| {
612 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
613 })?;
614 writer
615 .commit()
616 .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
617 self.reader
618 .reload()
619 .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
620 Ok(())
621 }
622
623 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
629 let opts = SearchOptions {
630 limit,
631 ..SearchOptions::default()
632 };
633 self.search_opt(query, &opts)
634 }
635
636 pub fn search_in_galaxy(
642 &self,
643 query: &str,
644 galaxy: Option<Galaxy>,
645 limit: usize,
646 ) -> Result<Vec<SearchResult>> {
647 let opts = SearchOptions {
648 limit,
649 galaxy,
650 ..SearchOptions::default()
651 };
652 self.search_opt(query, &opts)
653 }
654
655 pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
672 let stripped = strip_stopwords(query);
673 let sanitized = sanitize_tantivy_query(&stripped);
674 if sanitized.trim().is_empty() {
675 return Ok(Vec::new());
676 }
677
678 let searcher = self.reader.searcher();
679
680 let query_parser =
683 QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
684
685 let parsed = query_parser
686 .parse_query(&sanitized)
687 .map_err(|e| CoreError::Memory(format!("Tantivy parse_query: {e}")))?;
688
689 let collector = TopDocs::with_limit(opts.limit).order_by_score();
690
691 let top_docs = searcher
692 .search(&parsed, &collector)
693 .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
694
695 let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
696 let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
697 let relative_floor = opts
698 .relative_floor
699 .map_or(f32::MIN, |ratio| top_score * ratio);
700
701 let query_tokens = query_stem_tokens(&stripped);
705 let coverage_floor = if query_tokens.len() >= 3 { 2 } else { 1 };
706
707 let mut results = Vec::new();
708 for (score, doc_address) in top_docs {
709 if score < absolute_floor || score < relative_floor {
711 continue;
712 }
713
714 let doc: TantivyDocument = searcher
715 .doc(doc_address)
716 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
717
718 let memory_id = doc
719 .get_first(self.field_id)
720 .and_then(|v| v.as_str())
721 .unwrap_or("")
722 .to_string();
723
724 let doc_galaxy = doc
725 .get_first(self.field_galaxy)
726 .and_then(|v| v.as_str())
727 .unwrap_or("")
728 .to_string();
729
730 if let Some(g) = opts.galaxy {
731 if doc_galaxy != g.db_name() {
732 continue;
733 }
734 }
735
736 let content = doc
737 .get_first(self.field_content)
738 .and_then(|v| v.as_str())
739 .unwrap_or("")
740 .to_string();
741
742 if coverage_floor > 1 {
743 let hits = count_token_hits(&content, &stripped);
744 if hits < coverage_floor {
745 continue;
746 }
747 }
748
749 let boosted_score = if query_tokens.is_empty() {
752 score
753 } else {
754 let hits = count_token_hits(&content, &stripped);
755 let ratio = hits as f32 / query_tokens.len() as f32;
756 score * 0.1f32.mul_add(ratio, 1.0)
757 };
758
759 results.push(SearchResult {
760 memory_id,
761 galaxy: doc_galaxy,
762 score: boosted_score,
763 normalized_score: 0.0, content: scrub_text(&content),
765 });
766 }
767
768 results.sort_by(|a, b| {
770 b.score
771 .partial_cmp(&a.score)
772 .unwrap_or(std::cmp::Ordering::Equal)
773 });
774
775 let top_boosted = results.first().map_or(0.0, |r| r.score);
777 for r in &mut results {
778 r.normalized_score = if top_boosted > 0.0 {
779 r.score / top_boosted
780 } else {
781 0.0
782 };
783 }
784
785 Ok(results)
786 }
787
788 pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
790 let results = self.search(query, limit)?;
791 Ok(results
792 .into_iter()
793 .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
794 .collect())
795 }
796}
797
798#[must_use]
815pub fn sanitize_tantivy_query(input: &str) -> String {
816 if input.trim().is_empty() {
818 return String::new();
819 }
820
821 input
822 .split_whitespace()
823 .filter(|term| term.chars().any(char::is_alphanumeric))
824 .map(|term| {
825 if term_needs_quoting(term) {
826 let escaped = term.replace('"', "\\\"");
828 format!("\"{escaped}\"")
829 } else {
830 term.to_string()
831 }
832 })
833 .collect::<Vec<_>>()
834 .join(" ")
835}
836
837#[must_use]
839fn term_needs_quoting(term: &str) -> bool {
840 if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
841 return true;
842 }
843 if term == "AND" || term == "OR" || term == "NOT" {
844 return true;
845 }
846 if term.contains("&&") || term.contains("||") {
847 return true;
848 }
849 term.chars().any(|c| {
850 matches!(
851 c,
852 '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
853 )
854 })
855}
856
857#[must_use]
861pub fn strip_stopwords(query: &str) -> String {
862 query
863 .split_whitespace()
864 .filter(|term| !STOPWORDS.contains(&term.to_lowercase().as_str()))
865 .collect::<Vec<_>>()
866 .join(" ")
867}
868
869#[must_use]
873fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
874 stem_tokens(stripped_query)
875}
876
877#[must_use]
881fn stem_tokens(text: &str) -> Vec<String> {
882 let mut tokens: Vec<String> = Vec::new();
883 for term in text
884 .split(|c: char| !c.is_alphanumeric())
885 .filter(|term| term.len() > 1)
886 {
887 let stemmed = simple_stem(&term.to_lowercase());
888 if !tokens.contains(&stemmed) {
889 tokens.push(stemmed);
890 }
891 }
892 tokens
893}
894
895#[must_use]
901fn simple_stem(word: &str) -> String {
902 if word.len() <= 3 {
903 return word.to_string();
904 }
905 for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
907 if let Some(stem) = word.strip_suffix(suffix) {
908 if suffix == "ies" || suffix == "ied" {
910 return format!("{stem}y");
911 }
912 if stem.len() >= 2 {
914 return stem.to_string();
915 }
916 }
917 }
918 word.to_string()
919}
920
921#[must_use]
925fn count_token_hits(content: &str, stripped_query: &str) -> usize {
926 let query_tokens = query_stem_tokens(stripped_query);
927 if query_tokens.is_empty() {
928 return 0;
929 }
930 let content_stems: std::collections::HashSet<String> =
931 stem_tokens(content).into_iter().collect();
932 query_tokens
933 .iter()
934 .filter(|t| content_stems.contains(*t))
935 .count()
936}
937
938#[must_use]
948pub fn sanitize_content_for_index(content: &str) -> Option<String> {
949 if content.trim().is_empty() {
950 return None;
951 }
952 if content.as_bytes().contains(&0) {
953 return None;
954 }
955
956 let total = content.chars().count();
957 if total == 0 {
958 return None;
959 }
960 let printable = content.chars().filter(|c| !c.is_control()).count();
961 if (printable as f32 / total as f32) < MIN_PRINTABLE_RATIO {
962 return None;
963 }
964
965 let cleaned = scrub_text(content);
966 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
967 if capped.trim().is_empty() {
968 None
969 } else {
970 Some(capped)
971 }
972}
973
974#[must_use]
978pub fn scrub_text(content: &str) -> String {
979 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
980 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
981 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
982 out.push(' ');
983 } else {
984 out.push(c);
985 }
986 }
987 out
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993 use tempfile::tempdir;
994
995 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
996 let tmp = tempdir().unwrap();
997 let engine = SearchEngine::open(tmp.path()).unwrap();
998 (tmp, engine)
999 }
1000
1001 fn write_incompatible_index(dir: &Path) {
1004 std::fs::create_dir_all(dir).unwrap();
1005 let mut builder = Schema::builder();
1006 builder.add_text_field("legacy", STRING | STORED);
1007 let schema = builder.build();
1008 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1009 Index::open_or_create(directory, schema).unwrap();
1010 }
1011
1012 #[test]
1013 fn open_migrates_incompatible_schema() {
1014 let tmp = tempdir().unwrap();
1015 let dir = tmp.path().join("tantivy");
1016 write_incompatible_index(&dir);
1017
1018 let engine = SearchEngine::open(&dir).unwrap();
1019 assert!(
1020 engine.schema_migrated(),
1021 "incompatible schema must trigger migration"
1022 );
1023
1024 let backups: Vec<_> = std::fs::read_dir(tmp.path())
1026 .unwrap()
1027 .filter_map(std::result::Result::ok)
1028 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1029 .collect();
1030 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1031
1032 let mut writer = engine.writer().unwrap();
1034 engine
1035 .add_document(
1036 &mut writer,
1037 "33333333-3333-3333-3333-333333333333",
1038 "codex",
1039 "fresh index after migration",
1040 &[],
1041 1700000000,
1042 )
1043 .unwrap();
1044 engine.commit(&mut writer).unwrap();
1045 let results = engine.search("fresh index", 10).unwrap();
1046 assert_eq!(results.len(), 1);
1047 }
1048
1049 #[test]
1050 fn writer_lock_error_names_path_and_hint() {
1051 let err = format_writer_lock_error(
1055 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1056 Path::new("/store/x/tantivy"),
1057 );
1058 assert!(
1059 err.contains("/store/x/tantivy"),
1060 "must name the index path: {err}"
1061 );
1062 assert!(
1063 err.contains("pgrep -af wm"),
1064 "must include the diagnostic hint: {err}"
1065 );
1066 assert!(
1067 err.contains("--readonly"),
1068 "must offer the readonly alternative: {err}"
1069 );
1070
1071 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1073 assert!(other.starts_with("Tantivy writer: disk full"));
1074 assert!(!other.contains("pgrep"));
1075 }
1076
1077 #[test]
1078 fn open_readonly_rejects_incompatible_schema() {
1079 let tmp = tempdir().unwrap();
1080 let dir = tmp.path().join("tantivy");
1081 write_incompatible_index(&dir);
1082
1083 let err = match SearchEngine::open_readonly(&dir) {
1084 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1085 Err(e) => e,
1086 };
1087 assert!(
1088 format!("{err}").contains("wm reindex"),
1089 "read-only mismatch must point at wm reindex, got: {err}"
1090 );
1091
1092 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1094 .unwrap()
1095 .filter_map(std::result::Result::ok)
1096 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1097 .collect();
1098 assert!(siblings.is_empty(), "read-only open must not migrate");
1099 }
1100
1101 #[test]
1102 fn reopen_matching_schema_not_migrated() {
1103 let tmp = tempdir().unwrap();
1104 let dir = tmp.path().join("tantivy");
1105 std::fs::create_dir_all(&dir).unwrap();
1106
1107 let first = SearchEngine::open(&dir).unwrap();
1108 assert!(!first.schema_migrated());
1109 drop(first); let second = SearchEngine::open(&dir).unwrap();
1112 assert!(
1113 !second.schema_migrated(),
1114 "matching schema must not migrate"
1115 );
1116 drop(second);
1117
1118 let third = SearchEngine::open_readonly(&dir).unwrap();
1119 assert!(!third.schema_migrated());
1120 }
1121
1122 #[test]
1123 fn index_and_search_basic() {
1124 let (_tmp, engine) = open_engine();
1125 let mut writer = engine.writer().unwrap();
1126
1127 engine
1128 .add_document(
1129 &mut writer,
1130 "11111111-1111-1111-1111-111111111111",
1131 "codex",
1132 "The Rust programming language is fast and safe",
1133 &["rust".into(), "programming".into()],
1134 1700000000,
1135 )
1136 .unwrap();
1137 engine
1138 .add_document(
1139 &mut writer,
1140 "22222222-2222-2222-2222-222222222222",
1141 "codex",
1142 "Python is great for data science",
1143 &["python".into(), "data".into()],
1144 1700000001,
1145 )
1146 .unwrap();
1147 engine.commit(&mut writer).unwrap();
1148
1149 let results = engine.search("rust", 10).unwrap();
1150 assert!(!results.is_empty());
1151 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1152 }
1153
1154 #[test]
1155 fn search_by_tag() {
1156 let (_tmp, engine) = open_engine();
1157 let mut writer = engine.writer().unwrap();
1158
1159 engine
1160 .add_document(
1161 &mut writer,
1162 "11111111-1111-1111-1111-111111111111",
1163 "codex",
1164 "memory about systems",
1165 &["rust".into()],
1166 1700000000,
1167 )
1168 .unwrap();
1169 engine
1170 .add_document(
1171 &mut writer,
1172 "22222222-2222-2222-2222-222222222222",
1173 "codex",
1174 "memory about cooking",
1175 &["food".into()],
1176 1700000001,
1177 )
1178 .unwrap();
1179 engine.commit(&mut writer).unwrap();
1180
1181 let results = engine.search("rust", 10).unwrap();
1182 assert_eq!(results.len(), 1);
1183 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1184 }
1185
1186 #[test]
1187 fn search_filtered_by_galaxy() {
1188 let (_tmp, engine) = open_engine();
1189 let mut writer = engine.writer().unwrap();
1190
1191 engine
1192 .add_document(
1193 &mut writer,
1194 "11111111-1111-1111-1111-111111111111",
1195 "codex",
1196 "important knowledge",
1197 &[],
1198 1700000000,
1199 )
1200 .unwrap();
1201 engine
1202 .add_document(
1203 &mut writer,
1204 "22222222-2222-2222-2222-222222222222",
1205 "research",
1206 "important findings",
1207 &[],
1208 1700000001,
1209 )
1210 .unwrap();
1211 engine.commit(&mut writer).unwrap();
1212
1213 let results = engine
1214 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1215 .unwrap();
1216 assert_eq!(results.len(), 1);
1217 assert_eq!(results[0].galaxy, "codex");
1218 }
1219
1220 #[test]
1221 fn delete_document_from_index() {
1222 let (_tmp, engine) = open_engine();
1223 let mut writer = engine.writer().unwrap();
1224
1225 engine
1226 .add_document(
1227 &mut writer,
1228 "11111111-1111-1111-1111-111111111111",
1229 "codex",
1230 "deletable content",
1231 &[],
1232 1700000000,
1233 )
1234 .unwrap();
1235 engine.commit(&mut writer).unwrap();
1236
1237 let results = engine.search("deletable", 10).unwrap();
1238 assert_eq!(results.len(), 1);
1239
1240 engine
1241 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1242 .unwrap();
1243 engine.commit(&mut writer).unwrap();
1244
1245 let results = engine.search("deletable", 10).unwrap();
1246 assert_eq!(results.len(), 0);
1247 }
1248
1249 #[test]
1250 fn search_empty_index() {
1251 let (_tmp, engine) = open_engine();
1252 let results = engine.search("anything", 10).unwrap();
1253 assert!(results.is_empty());
1254 }
1255
1256 #[test]
1257 fn search_ids_returns_uuids() {
1258 let (_tmp, engine) = open_engine();
1259 let mut writer = engine.writer().unwrap();
1260
1261 engine
1262 .add_document(
1263 &mut writer,
1264 "11111111-1111-1111-1111-111111111111",
1265 "codex",
1266 "unique content about rust",
1267 &[],
1268 1700000000,
1269 )
1270 .unwrap();
1271 engine.commit(&mut writer).unwrap();
1272
1273 let ids = engine.search_ids("rust", 10).unwrap();
1274 assert_eq!(ids.len(), 1);
1275 assert_eq!(
1276 ids[0],
1277 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1278 );
1279 }
1280
1281 #[test]
1284 fn sanitize_leaves_plain_terms_unquoted() {
1285 let result = sanitize_tantivy_query("hello world");
1286 assert_eq!(result, "hello world");
1287 }
1288
1289 #[test]
1290 fn sanitize_drops_punct_only_terms() {
1291 let result = sanitize_tantivy_query("*");
1292 assert_eq!(result, "");
1293 }
1295
1296 #[test]
1297 fn sanitize_escapes_boolean_operators() {
1298 let result = sanitize_tantivy_query("NOT secret");
1299 assert_eq!(result, "\"NOT\" secret");
1300 }
1301
1302 #[test]
1303 fn sanitize_escapes_field_syntax() {
1304 let result = sanitize_tantivy_query("content:secret");
1305 assert_eq!(result, "\"content:secret\"");
1306 }
1307
1308 #[test]
1309 fn sanitize_escapes_quotes() {
1310 let result = sanitize_tantivy_query("test\"injection");
1311 assert!(
1312 result.contains("\\\""),
1313 "embedded quotes should be escaped: {result}"
1314 );
1315 }
1316
1317 #[test]
1318 fn sanitize_empty_returns_empty() {
1319 assert_eq!(sanitize_tantivy_query(""), "");
1320 assert_eq!(sanitize_tantivy_query(" "), "");
1321 }
1322
1323 #[test]
1324 fn sanitize_preserves_alphanumeric() {
1325 let result = sanitize_tantivy_query("rust programming 2024");
1326 assert_eq!(result, "rust programming 2024");
1327 }
1328
1329 #[test]
1330 fn sanitize_preserves_hyphenated_compounds() {
1331 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1332 assert_eq!(result, "antigravity antigravity-project-test");
1333 }
1334
1335 #[test]
1338 fn strip_stopwords_removes_common_words() {
1339 assert_eq!(
1340 strip_stopwords("smoke test from wmClient"),
1341 "smoke test wmClient"
1342 );
1343 assert_eq!(strip_stopwords("the from and or"), "");
1344 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1345 assert_eq!(strip_stopwords(""), "");
1346 }
1347
1348 #[test]
1349 fn strip_stopwords_is_case_insensitive() {
1350 assert_eq!(strip_stopwords("FROM The And"), "");
1351 }
1352
1353 #[test]
1356 fn sanitize_content_skips_null_bytes() {
1357 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1358 assert!(sanitize_content_for_index(content).is_none());
1359 }
1360
1361 #[test]
1362 fn sanitize_content_skips_low_printable_ratio() {
1363 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1365 assert!(sanitize_content_for_index(content).is_none());
1366 }
1367
1368 #[test]
1369 fn sanitize_content_scrubs_and_caps() {
1370 let content = "clean text\u{01}with one control char";
1372 let cleaned = sanitize_content_for_index(content).unwrap();
1373 assert!(!cleaned.contains('\u{01}'));
1374 assert!(cleaned.starts_with("clean text with one control char"));
1375
1376 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1377 let capped = sanitize_content_for_index(&long).unwrap();
1378 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1379 }
1380
1381 #[test]
1382 fn sanitize_content_skips_empty() {
1383 assert!(sanitize_content_for_index("").is_none());
1384 assert!(sanitize_content_for_index(" ").is_none());
1385 }
1386
1387 #[test]
1388 fn scrub_text_replaces_control_chars() {
1389 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1390 assert_eq!(result, "a b\nc\td e");
1391 }
1392
1393 #[test]
1394 fn add_document_skips_binary_content() {
1395 let (_tmp, engine) = open_engine();
1396 let mut writer = engine.writer().unwrap();
1397
1398 engine
1399 .add_document(
1400 &mut writer,
1401 "11111111-1111-1111-1111-111111111111",
1402 "codex",
1403 "\u{00}\u{01}\u{02}raw serialized bytes",
1404 &[],
1405 1700000000,
1406 )
1407 .unwrap();
1408 engine
1409 .add_document(
1410 &mut writer,
1411 "22222222-2222-2222-2222-222222222222",
1412 "codex",
1413 "clean searchable text",
1414 &[],
1415 1700000001,
1416 )
1417 .unwrap();
1418 engine.commit(&mut writer).unwrap();
1419
1420 let results = engine.search("serialized", 10).unwrap();
1422 assert!(results.is_empty(), "binary content must not be indexed");
1423
1424 let results = engine.search("clean", 10).unwrap();
1425 assert_eq!(results.len(), 1);
1426 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1427 }
1428
1429 fn index_alpha_pair(engine: &SearchEngine) {
1432 let mut writer = engine.writer().unwrap();
1433 engine
1436 .add_document(
1437 &mut writer,
1438 "11111111-1111-1111-1111-111111111111",
1439 "codex",
1440 "alpha",
1441 &[],
1442 1700000000,
1443 )
1444 .unwrap();
1445 let filler = format!("alpha {}", "zzz ".repeat(400));
1446 engine
1447 .add_document(
1448 &mut writer,
1449 "22222222-2222-2222-2222-222222222222",
1450 "codex",
1451 &filler,
1452 &[],
1453 1700000001,
1454 )
1455 .unwrap();
1456 engine.commit(&mut writer).unwrap();
1457 }
1458
1459 #[test]
1460 fn search_absolute_min_score_filters_weak_matches() {
1461 let (_tmp, engine) = open_engine();
1462 index_alpha_pair(&engine);
1463
1464 let base = engine.search("alpha", 10).unwrap();
1465 assert_eq!(base.len(), 2);
1466 let (hi, lo) = if base[0].score >= base[1].score {
1467 (base[0].score, base[1].score)
1468 } else {
1469 (base[1].score, base[0].score)
1470 };
1471 assert!(
1472 hi > lo,
1473 "short doc should outscore long doc (hi={hi}, lo={lo})"
1474 );
1475 let mid = f32::midpoint(hi, lo);
1476
1477 let opts = SearchOptions {
1478 limit: 10,
1479 min_score: Some(mid),
1480 ..SearchOptions::default()
1481 };
1482 let filtered = engine.search_opt("alpha", &opts).unwrap();
1483 assert_eq!(filtered.len(), 1);
1484 assert!((filtered[0].score - hi).abs() < 1e-3);
1485 }
1486
1487 #[test]
1488 fn search_relative_floor_filters_weak_matches() {
1489 let (_tmp, engine) = open_engine();
1490 index_alpha_pair(&engine);
1491
1492 let opts = SearchOptions {
1493 limit: 10,
1494 relative_floor: Some(0.5),
1495 ..SearchOptions::default()
1496 };
1497 let filtered = engine.search_opt("alpha", &opts).unwrap();
1498 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1499 assert_eq!(
1500 filtered[0].memory_id,
1501 "11111111-1111-1111-1111-111111111111"
1502 );
1503 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1504 }
1505
1506 #[test]
1507 fn search_all_results_normalized() {
1508 let (_tmp, engine) = open_engine();
1509 index_alpha_pair(&engine);
1510
1511 let results = engine.search("alpha", 10).unwrap();
1512 assert_eq!(results.len(), 2);
1513 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1514 for r in &results[1..] {
1515 assert!(r.normalized_score <= 1.0);
1516 assert!(r.normalized_score > 0.0);
1517 }
1518 }
1519
1520 #[test]
1521 fn search_stemming_matches_morphological_variants() {
1522 let (_tmp, engine) = open_engine();
1525 let mut writer = engine.writer().unwrap();
1526
1527 engine
1528 .add_document(
1529 &mut writer,
1530 "11111111-1111-1111-1111-111111111111",
1531 "codex",
1532 "I graduated with a degree in Business Administration",
1533 &[],
1534 1700000000,
1535 )
1536 .unwrap();
1537 engine.commit(&mut writer).unwrap();
1538
1539 let results = engine.search("graduate", 10).unwrap();
1541 assert_eq!(results.len(), 1);
1542 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1543
1544 let results = engine.search("degrees", 10).unwrap();
1546 assert_eq!(results.len(), 1);
1547 }
1548
1549 #[test]
1550 fn search_incident_query_returns_only_relevant() {
1551 let (_tmp, engine) = open_engine();
1554 let mut writer = engine.writer().unwrap();
1555
1556 let smoke_id = "11111111-1111-1111-1111-111111111111";
1557 engine
1558 .add_document(
1559 &mut writer,
1560 smoke_id,
1561 "codex",
1562 "smoke test from wmClient: verify recall works",
1563 &[],
1564 1700000000,
1565 )
1566 .unwrap();
1567 let unrelated = [
1568 "NES Evolution and Impact: a history of the console wars",
1569 "Insights on The Gateless Gate: koans and zen practice",
1570 "What the tweet is really saying: a thread analysis",
1571 ];
1572 for (i, content) in (1i64..).zip(unrelated.iter()) {
1573 engine
1574 .add_document(
1575 &mut writer,
1576 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1577 "codex",
1578 content,
1579 &[],
1580 1700000000 + i,
1581 )
1582 .unwrap();
1583 }
1584 engine.commit(&mut writer).unwrap();
1585
1586 let results = engine.search("smoke test from wmClient", 20).unwrap();
1587 assert_eq!(
1588 results.len(),
1589 1,
1590 "only the smoke memory should match: {results:?}"
1591 );
1592 assert_eq!(results[0].memory_id, smoke_id);
1593 assert!(results[0].content.contains("smoke test"));
1594 }
1595
1596 #[test]
1597 fn search_project_compound_query() {
1598 let (_tmp, engine) = open_engine();
1600 let mut writer = engine.writer().unwrap();
1601
1602 engine
1603 .add_document(
1604 &mut writer,
1605 "11111111-1111-1111-1111-111111111111",
1606 "codex",
1607 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1608 &["project_antigravity-project-test".into()],
1609 1700000000,
1610 )
1611 .unwrap();
1612 engine.commit(&mut writer).unwrap();
1613
1614 let results = engine
1615 .search("antigravity antigravity-project-test", 10)
1616 .unwrap();
1617 assert!(
1618 !results.is_empty(),
1619 "project compound query must match the antigravity memory"
1620 );
1621 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1622 }
1623
1624 #[test]
1625 fn or_default_filters_partial_matches_via_coverage() {
1626 let (_tmp, engine) = open_engine();
1627 let mut writer = engine.writer().unwrap();
1628
1629 engine
1630 .add_document(
1631 &mut writer,
1632 "11111111-1111-1111-1111-111111111111",
1633 "codex",
1634 "alpha beta gamma delta",
1635 &[],
1636 1700000000,
1637 )
1638 .unwrap();
1639 engine
1640 .add_document(
1641 &mut writer,
1642 "22222222-2222-2222-2222-222222222222",
1643 "codex",
1644 "alpha only here",
1645 &[],
1646 1700000001,
1647 )
1648 .unwrap();
1649 engine.commit(&mut writer).unwrap();
1650
1651 let results = engine.search("alpha beta gamma", 10).unwrap();
1654 assert_eq!(results.len(), 1);
1655 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1656
1657 let results = engine.search("alpha beta", 10).unwrap();
1659 assert_eq!(results.len(), 2);
1660 }
1661
1662 #[test]
1663 fn coverage_is_case_insensitive() {
1664 let (_tmp, engine) = open_engine();
1665 let mut writer = engine.writer().unwrap();
1666 engine
1667 .add_document(
1668 &mut writer,
1669 "11111111-1111-1111-1111-111111111111",
1670 "codex",
1671 "Smoke Test for wmClient integration",
1672 &[],
1673 1700000000,
1674 )
1675 .unwrap();
1676 engine
1677 .add_document(
1678 &mut writer,
1679 "22222222-2222-2222-2222-222222222222",
1680 "codex",
1681 "test only here",
1682 &[],
1683 1700000001,
1684 )
1685 .unwrap();
1686 engine.commit(&mut writer).unwrap();
1687
1688 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1693 assert_eq!(results.len(), 1);
1694 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1695 }
1696
1697 #[test]
1698 fn coverage_matches_stemmed_variants() {
1699 let (_tmp, engine) = open_engine();
1702 let mut writer = engine.writer().unwrap();
1703 engine
1704 .add_document(
1705 &mut writer,
1706 "11111111-1111-1111-1111-111111111111",
1707 "codex",
1708 "I graduated with a degree in Business Administration",
1709 &[],
1710 1700000000,
1711 )
1712 .unwrap();
1713 engine
1714 .add_document(
1715 &mut writer,
1716 "22222222-2222-2222-2222-222222222222",
1717 "codex",
1718 "degree only here",
1719 &[],
1720 1700000001,
1721 )
1722 .unwrap();
1723 engine.commit(&mut writer).unwrap();
1724
1725 let results = engine.search("graduate degree", 10).unwrap();
1731 assert_eq!(results.len(), 2);
1732 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1733 }
1734
1735 #[test]
1736 fn coverage_normalizes_possessives_and_punctuation() {
1737 let query = "buy sister's birthday gift";
1738 assert_eq!(
1739 query_stem_tokens(query),
1740 ["buy", "sister", "birthday", "gift"]
1741 );
1742 assert_eq!(
1743 count_token_hits("I bought a dress for my sister birthday", query),
1744 2
1745 );
1746 }
1747
1748 #[test]
1749 fn search_stopword_only_query_returns_nothing() {
1750 let (_tmp, engine) = open_engine();
1751 let mut writer = engine.writer().unwrap();
1752 engine
1753 .add_document(
1754 &mut writer,
1755 "11111111-1111-1111-1111-111111111111",
1756 "codex",
1757 "some ordinary text",
1758 &[],
1759 1700000000,
1760 )
1761 .unwrap();
1762 engine.commit(&mut writer).unwrap();
1763
1764 let results = engine.search("the from and or", 10).unwrap();
1765 assert!(results.is_empty());
1766 }
1767
1768 #[test]
1769 fn wildcard_query_doesnt_match_all() {
1770 let (_tmp, engine) = open_engine();
1771 let mut writer = engine.writer().unwrap();
1772
1773 engine
1774 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
1775 .unwrap();
1776 engine
1777 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
1778 .unwrap();
1779 engine.commit(&mut writer).unwrap();
1780
1781 let results = engine.search("*", 10).unwrap();
1783 assert!(
1786 results.is_empty(),
1787 "wildcard query should not match all documents after sanitization"
1788 );
1789 }
1790
1791 #[test]
1792 fn field_syntax_query_doesnt_access_other_fields() {
1793 let (_tmp, engine) = open_engine();
1794 let mut writer = engine.writer().unwrap();
1795
1796 engine
1798 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
1799 .unwrap();
1800 engine.commit(&mut writer).unwrap();
1801
1802 let results = engine.search("galaxy:secret", 10).unwrap();
1804 assert!(
1807 results.is_empty(),
1808 "field syntax injection should not access non-searchable fields"
1809 );
1810 }
1811
1812 #[test]
1813 fn boolean_operator_doesnt_bypass_search() {
1814 let (_tmp, engine) = open_engine();
1815 let mut writer = engine.writer().unwrap();
1816
1817 engine
1818 .add_document(
1819 &mut writer,
1820 "uuid-1",
1821 "codex",
1822 "important secret data",
1823 &[],
1824 1000,
1825 )
1826 .unwrap();
1827 engine.commit(&mut writer).unwrap();
1828
1829 let results = engine.search("AND secret", 10).unwrap();
1833 assert_eq!(results.len(), 1);
1834
1835 let results = engine.search("AND OR NOT", 10).unwrap();
1838 assert!(
1839 results.is_empty(),
1840 "operator-only query must not bypass search"
1841 );
1842 }
1843}