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 {
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
281pub 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 health: IndexHealth,
294 schema_migrated: bool,
300}
301
302impl SearchEngine {
303 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 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 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 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 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 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
436 let path = path.as_ref();
437 tracing::warn!(
440 "read-only search index opened at {} — it will not observe writes made \
441 after this point; restart the read-only server to pick up new memories",
442 path.display()
443 );
444 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
445 Self::build_schema();
446 let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
447 let reader = index
448 .reader_builder()
449 .reload_policy(ReloadPolicy::OnCommitWithDelay)
450 .try_into()
451 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
452 Ok(Self {
453 index,
454 reader,
455 writer: Mutex::new(None),
456 field_id,
457 field_galaxy,
458 field_content,
459 field_tags,
460 field_timestamp,
461 health: IndexHealth::default(),
462 schema_migrated,
463 })
464 }
465
466 #[must_use]
472 pub const fn schema_migrated(&self) -> bool {
473 self.schema_migrated
474 }
475
476 #[must_use]
479 pub const fn health(&self) -> &IndexHealth {
480 &self.health
481 }
482
483 pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
489 self.reader
493 .reload()
494 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
495 let searcher = self.reader.searcher();
496 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
497 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
498 let count = searcher
499 .search(&query, &tantivy::collector::Count)
500 .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
501 Ok(count)
502 }
503
504 pub fn indexed_ids_in_galaxy(&self, galaxy: &str) -> Result<std::collections::HashSet<String>> {
511 self.reader
515 .reload()
516 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
517 let searcher = self.reader.searcher();
518 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
519 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
520 let count = self.count_docs_in_galaxy(galaxy)?;
521 let hits: std::collections::HashSet<tantivy::DocAddress> = searcher
522 .search(&query, &tantivy::collector::DocSetCollector)
523 .map_err(|e| CoreError::Memory(format!("Tantivy indexed_ids: {e}")))?;
524 let mut out = std::collections::HashSet::with_capacity(count);
525 for addr in hits {
526 let doc: TantivyDocument = searcher
527 .doc(addr)
528 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
529 if let Some(id) = doc.get_first(self.field_id).and_then(|v| v.as_str()) {
530 out.insert(id.to_string());
531 }
532 }
533 Ok(out)
534 }
535
536 pub fn is_readonly(&self) -> bool {
538 self.writer.lock().map_or(true, |g| g.is_none())
539 }
540
541 pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
547 let guard = self
548 .writer
549 .lock()
550 .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
551 if guard.is_none() {
552 return Err(CoreError::Memory(
553 "Tantivy writer unavailable: index opened read-only".into(),
554 ));
555 }
556 Ok(guard)
557 }
558
559 pub fn add_document(
566 &self,
567 writer: &mut Option<IndexWriter>,
568 memory_id: &str,
569 galaxy: &str,
570 content: &str,
571 tags: &[String],
572 timestamp: i64,
573 ) -> Result<()> {
574 let writer = writer.as_mut().ok_or_else(|| {
575 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
576 })?;
577 let Some(clean_content) = sanitize_content_for_index(content) else {
578 tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
579 return Ok(());
580 };
581 let tags_str = tags.join(" ");
582 let doc = doc!(
583 self.field_id => memory_id,
584 self.field_galaxy => galaxy,
585 self.field_content => clean_content,
586 self.field_tags => tags_str,
587 self.field_timestamp => timestamp,
588 );
589 match writer.add_document(doc) {
590 Ok(_) => {
591 self.health.record_success();
592 Ok(())
593 }
594 Err(e) => {
595 let msg = format!("Tantivy add_document: {e}");
596 self.health.record_failure(&msg);
597 Err(CoreError::Memory(msg))
598 }
599 }
600 }
601
602 pub fn index_memory(
604 &self,
605 writer: &mut Option<IndexWriter>,
606 mem: &crate::memory::Memory,
607 ) -> Result<()> {
608 self.add_document(
609 writer,
610 &mem.metadata.id.to_string(),
611 mem.metadata.galaxy.db_name(),
612 &mem.content,
613 &mem.metadata.tags,
614 mem.metadata.created_at.timestamp(),
615 )
616 }
617
618 pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
620 let writer = writer.as_mut().ok_or_else(|| {
621 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
622 })?;
623 let term = tantivy::Term::from_field_text(self.field_id, memory_id);
624 writer.delete_term(term);
625 Ok(())
626 }
627
628 pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
633 let writer = writer.as_mut().ok_or_else(|| {
634 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
635 })?;
636 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
637 writer.delete_term(term);
638 Ok(())
639 }
640
641 pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
643 let writer = writer.as_mut().ok_or_else(|| {
644 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
645 })?;
646 writer
647 .commit()
648 .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
649 self.reader
650 .reload()
651 .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
652 Ok(())
653 }
654
655 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
661 let opts = SearchOptions {
662 limit,
663 ..SearchOptions::default()
664 };
665 self.search_opt(query, &opts)
666 }
667
668 pub fn search_in_galaxy(
674 &self,
675 query: &str,
676 galaxy: Option<Galaxy>,
677 limit: usize,
678 ) -> Result<Vec<SearchResult>> {
679 let opts = SearchOptions {
680 limit,
681 galaxy,
682 ..SearchOptions::default()
683 };
684 self.search_opt(query, &opts)
685 }
686
687 pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
704 let stripped = strip_stopwords(query);
705 let sanitized = sanitize_tantivy_query(&stripped);
706 if sanitized.trim().is_empty() {
707 return Ok(Vec::new());
708 }
709
710 let searcher = self.reader.searcher();
711
712 let query_parser =
715 QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
716
717 let parsed = parse_query_with_fallback(&query_parser, &sanitized);
718
719 let collector = TopDocs::with_limit(opts.limit).order_by_score();
720
721 let top_docs = searcher
722 .search(&parsed, &collector)
723 .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
724
725 let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
726 let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
727 let relative_floor = opts
728 .relative_floor
729 .map_or(f32::MIN, |ratio| top_score * ratio);
730
731 let query_tokens = query_stem_tokens(&stripped);
735 let coverage_floor = if query_tokens.len() >= 3 { 2 } else { 1 };
736
737 let mut results = Vec::new();
738 for (score, doc_address) in top_docs {
739 if score < absolute_floor || score < relative_floor {
741 continue;
742 }
743
744 let doc: TantivyDocument = searcher
745 .doc(doc_address)
746 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
747
748 let memory_id = doc
749 .get_first(self.field_id)
750 .and_then(|v| v.as_str())
751 .unwrap_or("")
752 .to_string();
753
754 let doc_galaxy = doc
755 .get_first(self.field_galaxy)
756 .and_then(|v| v.as_str())
757 .unwrap_or("")
758 .to_string();
759
760 if let Some(g) = opts.galaxy {
761 if doc_galaxy != g.db_name() {
762 continue;
763 }
764 }
765
766 let content = doc
767 .get_first(self.field_content)
768 .and_then(|v| v.as_str())
769 .unwrap_or("")
770 .to_string();
771
772 if coverage_floor > 1 {
773 let hits = count_token_hits(&content, &stripped);
774 if hits < coverage_floor {
775 continue;
776 }
777 }
778
779 let boosted_score = if query_tokens.is_empty() {
782 score
783 } else {
784 let hits = count_token_hits(&content, &stripped);
785 let ratio = hits as f32 / query_tokens.len() as f32;
786 score * 0.1f32.mul_add(ratio, 1.0)
787 };
788
789 results.push(SearchResult {
790 memory_id,
791 galaxy: doc_galaxy,
792 score: boosted_score,
793 normalized_score: 0.0, content: scrub_text(&content),
795 });
796 }
797
798 results.sort_by(|a, b| {
800 b.score
801 .partial_cmp(&a.score)
802 .unwrap_or(std::cmp::Ordering::Equal)
803 });
804
805 let top_boosted = results.first().map_or(0.0, |r| r.score);
807 for r in &mut results {
808 r.normalized_score = if top_boosted > 0.0 {
809 r.score / top_boosted
810 } else {
811 0.0
812 };
813 }
814
815 Ok(results)
816 }
817
818 pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
820 let results = self.search(query, limit)?;
821 Ok(results
822 .into_iter()
823 .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
824 .collect())
825 }
826}
827
828fn parse_query_with_fallback(parser: &QueryParser, query: &str) -> Box<dyn tantivy::query::Query> {
837 match parser.parse_query(query) {
838 Ok(parsed) => parsed,
839 Err(_) => parser.parse_query_lenient(query).0,
840 }
841}
842
843#[must_use]
860pub fn sanitize_tantivy_query(input: &str) -> String {
861 if input.trim().is_empty() {
863 return String::new();
864 }
865
866 input
867 .split_whitespace()
868 .filter(|term| term.chars().any(char::is_alphanumeric))
869 .map(|term| {
870 if term_needs_quoting(term) {
871 let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
875 format!("\"{escaped}\"")
876 } else {
877 term.to_string()
878 }
879 })
880 .collect::<Vec<_>>()
881 .join(" ")
882}
883
884#[must_use]
886fn term_needs_quoting(term: &str) -> bool {
887 if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
888 return true;
889 }
890 if term == "AND" || term == "OR" || term == "NOT" {
891 return true;
892 }
893 if term.contains("&&") || term.contains("||") {
894 return true;
895 }
896 term.chars().any(|c| {
897 matches!(
898 c,
899 '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
900 )
901 })
902}
903
904#[must_use]
908pub fn strip_stopwords(query: &str) -> String {
909 query
910 .split_whitespace()
911 .filter(|term| !STOPWORDS.contains(&term.to_lowercase().as_str()))
912 .collect::<Vec<_>>()
913 .join(" ")
914}
915
916#[must_use]
920fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
921 stem_tokens(stripped_query)
922}
923
924#[must_use]
928fn stem_tokens(text: &str) -> Vec<String> {
929 let mut tokens: Vec<String> = Vec::new();
930 for term in text
931 .split(|c: char| !c.is_alphanumeric())
932 .filter(|term| term.len() > 1)
933 {
934 let stemmed = simple_stem(&term.to_lowercase());
935 if !tokens.contains(&stemmed) {
936 tokens.push(stemmed);
937 }
938 }
939 tokens
940}
941
942#[must_use]
948fn simple_stem(word: &str) -> String {
949 if word.len() <= 3 {
950 return word.to_string();
951 }
952 for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
954 if let Some(stem) = word.strip_suffix(suffix) {
955 if suffix == "ies" || suffix == "ied" {
957 return format!("{stem}y");
958 }
959 if stem.len() >= 2 {
961 return stem.to_string();
962 }
963 }
964 }
965 word.to_string()
966}
967
968#[must_use]
972fn count_token_hits(content: &str, stripped_query: &str) -> usize {
973 let query_tokens = query_stem_tokens(stripped_query);
974 if query_tokens.is_empty() {
975 return 0;
976 }
977 let content_stems: std::collections::HashSet<String> =
978 stem_tokens(content).into_iter().collect();
979 query_tokens
980 .iter()
981 .filter(|t| content_stems.contains(*t))
982 .count()
983}
984
985#[must_use]
995pub fn sanitize_content_for_index(content: &str) -> Option<String> {
996 if content.trim().is_empty() {
997 return None;
998 }
999 if content.as_bytes().contains(&0) {
1000 return None;
1001 }
1002
1003 let total = content.chars().count();
1004 if total == 0 {
1005 return None;
1006 }
1007 let printable = content.chars().filter(|c| !c.is_control()).count();
1008 if (printable as f32 / total as f32) < MIN_PRINTABLE_RATIO {
1009 return None;
1010 }
1011
1012 let cleaned = scrub_text(content);
1013 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1014 if capped.trim().is_empty() {
1015 None
1016 } else {
1017 Some(capped)
1018 }
1019}
1020
1021#[must_use]
1025pub fn scrub_text(content: &str) -> String {
1026 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1027 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1028 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1029 out.push(' ');
1030 } else {
1031 out.push(c);
1032 }
1033 }
1034 out
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040 use tempfile::tempdir;
1041
1042 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1043 let tmp = tempdir().unwrap();
1044 let engine = SearchEngine::open(tmp.path()).unwrap();
1045 (tmp, engine)
1046 }
1047
1048 fn write_incompatible_index(dir: &Path) {
1051 std::fs::create_dir_all(dir).unwrap();
1052 let mut builder = Schema::builder();
1053 builder.add_text_field("legacy", STRING | STORED);
1054 let schema = builder.build();
1055 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1056 Index::open_or_create(directory, schema).unwrap();
1057 }
1058
1059 #[test]
1060 fn open_migrates_incompatible_schema() {
1061 let tmp = tempdir().unwrap();
1062 let dir = tmp.path().join("tantivy");
1063 write_incompatible_index(&dir);
1064
1065 let engine = SearchEngine::open(&dir).unwrap();
1066 assert!(
1067 engine.schema_migrated(),
1068 "incompatible schema must trigger migration"
1069 );
1070
1071 let backups: Vec<_> = std::fs::read_dir(tmp.path())
1073 .unwrap()
1074 .filter_map(std::result::Result::ok)
1075 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1076 .collect();
1077 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1078
1079 let mut writer = engine.writer().unwrap();
1081 engine
1082 .add_document(
1083 &mut writer,
1084 "33333333-3333-3333-3333-333333333333",
1085 "codex",
1086 "fresh index after migration",
1087 &[],
1088 1700000000,
1089 )
1090 .unwrap();
1091 engine.commit(&mut writer).unwrap();
1092 let results = engine.search("fresh index", 10).unwrap();
1093 assert_eq!(results.len(), 1);
1094 }
1095
1096 #[test]
1097 fn writer_lock_error_names_path_and_hint() {
1098 let err = format_writer_lock_error(
1102 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1103 Path::new("/store/x/tantivy"),
1104 );
1105 assert!(
1106 err.contains("/store/x/tantivy"),
1107 "must name the index path: {err}"
1108 );
1109 assert!(
1110 err.contains("pgrep -af wm"),
1111 "must include the diagnostic hint: {err}"
1112 );
1113 assert!(
1114 err.contains("--readonly"),
1115 "must offer the readonly alternative: {err}"
1116 );
1117
1118 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1120 assert!(other.starts_with("Tantivy writer: disk full"));
1121 assert!(!other.contains("pgrep"));
1122 }
1123
1124 #[test]
1125 fn open_readonly_rejects_incompatible_schema() {
1126 let tmp = tempdir().unwrap();
1127 let dir = tmp.path().join("tantivy");
1128 write_incompatible_index(&dir);
1129
1130 let err = match SearchEngine::open_readonly(&dir) {
1131 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1132 Err(e) => e,
1133 };
1134 assert!(
1135 format!("{err}").contains("wm reindex"),
1136 "read-only mismatch must point at wm reindex, got: {err}"
1137 );
1138
1139 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1141 .unwrap()
1142 .filter_map(std::result::Result::ok)
1143 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1144 .collect();
1145 assert!(siblings.is_empty(), "read-only open must not migrate");
1146 }
1147
1148 #[test]
1149 fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1150 let tmp = tempdir().unwrap();
1151 let dir = tmp.path().join("tantivy");
1152 std::fs::create_dir_all(&dir).unwrap();
1153
1154 let err = match SearchEngine::open_readonly(&dir) {
1155 Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1156 Err(err) => err,
1157 };
1158 assert!(format!("{err}").contains("readonly open-existing"));
1159 assert!(
1160 std::fs::read_dir(&dir).unwrap().next().is_none(),
1161 "readonly open must not materialize Tantivy metadata or segments"
1162 );
1163 }
1164
1165 #[test]
1166 fn reopen_matching_schema_not_migrated() {
1167 let tmp = tempdir().unwrap();
1168 let dir = tmp.path().join("tantivy");
1169 std::fs::create_dir_all(&dir).unwrap();
1170
1171 let first = SearchEngine::open(&dir).unwrap();
1172 assert!(!first.schema_migrated());
1173 drop(first); let second = SearchEngine::open(&dir).unwrap();
1176 assert!(
1177 !second.schema_migrated(),
1178 "matching schema must not migrate"
1179 );
1180 drop(second);
1181
1182 let third = SearchEngine::open_readonly(&dir).unwrap();
1183 assert!(!third.schema_migrated());
1184 }
1185
1186 #[test]
1187 fn index_and_search_basic() {
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 "The Rust programming language is fast and safe",
1197 &["rust".into(), "programming".into()],
1198 1700000000,
1199 )
1200 .unwrap();
1201 engine
1202 .add_document(
1203 &mut writer,
1204 "22222222-2222-2222-2222-222222222222",
1205 "codex",
1206 "Python is great for data science",
1207 &["python".into(), "data".into()],
1208 1700000001,
1209 )
1210 .unwrap();
1211 engine.commit(&mut writer).unwrap();
1212
1213 let results = engine.search("rust", 10).unwrap();
1214 assert!(!results.is_empty());
1215 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1216 }
1217
1218 #[test]
1219 fn search_by_tag() {
1220 let (_tmp, engine) = open_engine();
1221 let mut writer = engine.writer().unwrap();
1222
1223 engine
1224 .add_document(
1225 &mut writer,
1226 "11111111-1111-1111-1111-111111111111",
1227 "codex",
1228 "memory about systems",
1229 &["rust".into()],
1230 1700000000,
1231 )
1232 .unwrap();
1233 engine
1234 .add_document(
1235 &mut writer,
1236 "22222222-2222-2222-2222-222222222222",
1237 "codex",
1238 "memory about cooking",
1239 &["food".into()],
1240 1700000001,
1241 )
1242 .unwrap();
1243 engine.commit(&mut writer).unwrap();
1244
1245 let results = engine.search("rust", 10).unwrap();
1246 assert_eq!(results.len(), 1);
1247 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1248 }
1249
1250 #[test]
1251 fn search_filtered_by_galaxy() {
1252 let (_tmp, engine) = open_engine();
1253 let mut writer = engine.writer().unwrap();
1254
1255 engine
1256 .add_document(
1257 &mut writer,
1258 "11111111-1111-1111-1111-111111111111",
1259 "codex",
1260 "important knowledge",
1261 &[],
1262 1700000000,
1263 )
1264 .unwrap();
1265 engine
1266 .add_document(
1267 &mut writer,
1268 "22222222-2222-2222-2222-222222222222",
1269 "research",
1270 "important findings",
1271 &[],
1272 1700000001,
1273 )
1274 .unwrap();
1275 engine.commit(&mut writer).unwrap();
1276
1277 let results = engine
1278 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1279 .unwrap();
1280 assert_eq!(results.len(), 1);
1281 assert_eq!(results[0].galaxy, "codex");
1282 }
1283
1284 #[test]
1285 fn delete_document_from_index() {
1286 let (_tmp, engine) = open_engine();
1287 let mut writer = engine.writer().unwrap();
1288
1289 engine
1290 .add_document(
1291 &mut writer,
1292 "11111111-1111-1111-1111-111111111111",
1293 "codex",
1294 "deletable content",
1295 &[],
1296 1700000000,
1297 )
1298 .unwrap();
1299 engine.commit(&mut writer).unwrap();
1300
1301 let results = engine.search("deletable", 10).unwrap();
1302 assert_eq!(results.len(), 1);
1303
1304 engine
1305 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1306 .unwrap();
1307 engine.commit(&mut writer).unwrap();
1308
1309 let results = engine.search("deletable", 10).unwrap();
1310 assert_eq!(results.len(), 0);
1311 }
1312
1313 #[test]
1314 fn search_empty_index() {
1315 let (_tmp, engine) = open_engine();
1316 let results = engine.search("anything", 10).unwrap();
1317 assert!(results.is_empty());
1318 }
1319
1320 #[test]
1321 fn search_ids_returns_uuids() {
1322 let (_tmp, engine) = open_engine();
1323 let mut writer = engine.writer().unwrap();
1324
1325 engine
1326 .add_document(
1327 &mut writer,
1328 "11111111-1111-1111-1111-111111111111",
1329 "codex",
1330 "unique content about rust",
1331 &[],
1332 1700000000,
1333 )
1334 .unwrap();
1335 engine.commit(&mut writer).unwrap();
1336
1337 let ids = engine.search_ids("rust", 10).unwrap();
1338 assert_eq!(ids.len(), 1);
1339 assert_eq!(
1340 ids[0],
1341 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1342 );
1343 }
1344
1345 #[test]
1348 fn sanitize_leaves_plain_terms_unquoted() {
1349 let result = sanitize_tantivy_query("hello world");
1350 assert_eq!(result, "hello world");
1351 }
1352
1353 #[test]
1354 fn sanitize_drops_punct_only_terms() {
1355 let result = sanitize_tantivy_query("*");
1356 assert_eq!(result, "");
1357 }
1359
1360 #[test]
1361 fn sanitize_escapes_boolean_operators() {
1362 let result = sanitize_tantivy_query("NOT secret");
1363 assert_eq!(result, "\"NOT\" secret");
1364 }
1365
1366 #[test]
1367 fn sanitize_escapes_field_syntax() {
1368 let result = sanitize_tantivy_query("content:secret");
1369 assert_eq!(result, "\"content:secret\"");
1370 }
1371
1372 #[test]
1373 fn sanitize_escapes_quotes() {
1374 let result = sanitize_tantivy_query("test\"injection");
1375 assert!(
1376 result.contains("\\\""),
1377 "embedded quotes should be escaped: {result}"
1378 );
1379 }
1380
1381 #[test]
1382 fn sanitize_escapes_trailing_backslash_token() {
1383 let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1386 assert_eq!(
1387 result, "\"C:\\\\Users\\\\temp\\\\\"",
1388 "backslashes must be doubled inside quoted terms"
1389 );
1390 }
1391
1392 #[test]
1393 fn lenient_fallback_never_fails_on_malformed_input() {
1394 let (_tmp, engine) = open_engine();
1395 let parser =
1396 QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1397 let searcher = engine.reader.searcher();
1398 let collector = TopDocs::with_limit(1).order_by_score();
1399 for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1400 let parsed = parse_query_with_fallback(&parser, malformed);
1401 searcher
1402 .search(&parsed, &collector)
1403 .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1404 }
1405 }
1406
1407 #[test]
1408 fn sanitize_empty_returns_empty() {
1409 assert_eq!(sanitize_tantivy_query(""), "");
1410 assert_eq!(sanitize_tantivy_query(" "), "");
1411 }
1412
1413 #[test]
1414 fn sanitize_preserves_alphanumeric() {
1415 let result = sanitize_tantivy_query("rust programming 2024");
1416 assert_eq!(result, "rust programming 2024");
1417 }
1418
1419 #[test]
1420 fn sanitize_preserves_hyphenated_compounds() {
1421 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1422 assert_eq!(result, "antigravity antigravity-project-test");
1423 }
1424
1425 #[test]
1428 fn strip_stopwords_removes_common_words() {
1429 assert_eq!(
1430 strip_stopwords("smoke test from wmClient"),
1431 "smoke test wmClient"
1432 );
1433 assert_eq!(strip_stopwords("the from and or"), "");
1434 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1435 assert_eq!(strip_stopwords(""), "");
1436 }
1437
1438 #[test]
1439 fn strip_stopwords_is_case_insensitive() {
1440 assert_eq!(strip_stopwords("FROM The And"), "");
1441 }
1442
1443 #[test]
1446 fn sanitize_content_skips_null_bytes() {
1447 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1448 assert!(sanitize_content_for_index(content).is_none());
1449 }
1450
1451 #[test]
1452 fn sanitize_content_skips_low_printable_ratio() {
1453 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1455 assert!(sanitize_content_for_index(content).is_none());
1456 }
1457
1458 #[test]
1459 fn sanitize_content_scrubs_and_caps() {
1460 let content = "clean text\u{01}with one control char";
1462 let cleaned = sanitize_content_for_index(content).unwrap();
1463 assert!(!cleaned.contains('\u{01}'));
1464 assert!(cleaned.starts_with("clean text with one control char"));
1465
1466 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1467 let capped = sanitize_content_for_index(&long).unwrap();
1468 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1469 }
1470
1471 #[test]
1472 fn sanitize_content_skips_empty() {
1473 assert!(sanitize_content_for_index("").is_none());
1474 assert!(sanitize_content_for_index(" ").is_none());
1475 }
1476
1477 #[test]
1478 fn scrub_text_replaces_control_chars() {
1479 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1480 assert_eq!(result, "a b\nc\td e");
1481 }
1482
1483 #[test]
1484 fn add_document_skips_binary_content() {
1485 let (_tmp, engine) = open_engine();
1486 let mut writer = engine.writer().unwrap();
1487
1488 engine
1489 .add_document(
1490 &mut writer,
1491 "11111111-1111-1111-1111-111111111111",
1492 "codex",
1493 "\u{00}\u{01}\u{02}raw serialized bytes",
1494 &[],
1495 1700000000,
1496 )
1497 .unwrap();
1498 engine
1499 .add_document(
1500 &mut writer,
1501 "22222222-2222-2222-2222-222222222222",
1502 "codex",
1503 "clean searchable text",
1504 &[],
1505 1700000001,
1506 )
1507 .unwrap();
1508 engine.commit(&mut writer).unwrap();
1509
1510 let results = engine.search("serialized", 10).unwrap();
1512 assert!(results.is_empty(), "binary content must not be indexed");
1513
1514 let results = engine.search("clean", 10).unwrap();
1515 assert_eq!(results.len(), 1);
1516 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1517 }
1518
1519 fn index_alpha_pair(engine: &SearchEngine) {
1522 let mut writer = engine.writer().unwrap();
1523 engine
1526 .add_document(
1527 &mut writer,
1528 "11111111-1111-1111-1111-111111111111",
1529 "codex",
1530 "alpha",
1531 &[],
1532 1700000000,
1533 )
1534 .unwrap();
1535 let filler = format!("alpha {}", "zzz ".repeat(400));
1536 engine
1537 .add_document(
1538 &mut writer,
1539 "22222222-2222-2222-2222-222222222222",
1540 "codex",
1541 &filler,
1542 &[],
1543 1700000001,
1544 )
1545 .unwrap();
1546 engine.commit(&mut writer).unwrap();
1547 }
1548
1549 #[test]
1550 fn search_absolute_min_score_filters_weak_matches() {
1551 let (_tmp, engine) = open_engine();
1552 index_alpha_pair(&engine);
1553
1554 let base = engine.search("alpha", 10).unwrap();
1555 assert_eq!(base.len(), 2);
1556 let (hi, lo) = if base[0].score >= base[1].score {
1557 (base[0].score, base[1].score)
1558 } else {
1559 (base[1].score, base[0].score)
1560 };
1561 assert!(
1562 hi > lo,
1563 "short doc should outscore long doc (hi={hi}, lo={lo})"
1564 );
1565 let mid = f32::midpoint(hi, lo);
1566
1567 let opts = SearchOptions {
1568 limit: 10,
1569 min_score: Some(mid),
1570 ..SearchOptions::default()
1571 };
1572 let filtered = engine.search_opt("alpha", &opts).unwrap();
1573 assert_eq!(filtered.len(), 1);
1574 assert!((filtered[0].score - hi).abs() < 1e-3);
1575 }
1576
1577 #[test]
1578 fn search_relative_floor_filters_weak_matches() {
1579 let (_tmp, engine) = open_engine();
1580 index_alpha_pair(&engine);
1581
1582 let opts = SearchOptions {
1583 limit: 10,
1584 relative_floor: Some(0.5),
1585 ..SearchOptions::default()
1586 };
1587 let filtered = engine.search_opt("alpha", &opts).unwrap();
1588 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1589 assert_eq!(
1590 filtered[0].memory_id,
1591 "11111111-1111-1111-1111-111111111111"
1592 );
1593 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1594 }
1595
1596 #[test]
1597 fn search_all_results_normalized() {
1598 let (_tmp, engine) = open_engine();
1599 index_alpha_pair(&engine);
1600
1601 let results = engine.search("alpha", 10).unwrap();
1602 assert_eq!(results.len(), 2);
1603 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1604 for r in &results[1..] {
1605 assert!(r.normalized_score <= 1.0);
1606 assert!(r.normalized_score > 0.0);
1607 }
1608 }
1609
1610 #[test]
1611 fn search_stemming_matches_morphological_variants() {
1612 let (_tmp, engine) = open_engine();
1615 let mut writer = engine.writer().unwrap();
1616
1617 engine
1618 .add_document(
1619 &mut writer,
1620 "11111111-1111-1111-1111-111111111111",
1621 "codex",
1622 "I graduated with a degree in Business Administration",
1623 &[],
1624 1700000000,
1625 )
1626 .unwrap();
1627 engine.commit(&mut writer).unwrap();
1628
1629 let results = engine.search("graduate", 10).unwrap();
1631 assert_eq!(results.len(), 1);
1632 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1633
1634 let results = engine.search("degrees", 10).unwrap();
1636 assert_eq!(results.len(), 1);
1637 }
1638
1639 #[test]
1640 fn search_incident_query_returns_only_relevant() {
1641 let (_tmp, engine) = open_engine();
1644 let mut writer = engine.writer().unwrap();
1645
1646 let smoke_id = "11111111-1111-1111-1111-111111111111";
1647 engine
1648 .add_document(
1649 &mut writer,
1650 smoke_id,
1651 "codex",
1652 "smoke test from wmClient: verify recall works",
1653 &[],
1654 1700000000,
1655 )
1656 .unwrap();
1657 let unrelated = [
1658 "NES Evolution and Impact: a history of the console wars",
1659 "Insights on The Gateless Gate: koans and zen practice",
1660 "What the tweet is really saying: a thread analysis",
1661 ];
1662 for (i, content) in (1i64..).zip(unrelated.iter()) {
1663 engine
1664 .add_document(
1665 &mut writer,
1666 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1667 "codex",
1668 content,
1669 &[],
1670 1700000000 + i,
1671 )
1672 .unwrap();
1673 }
1674 engine.commit(&mut writer).unwrap();
1675
1676 let results = engine.search("smoke test from wmClient", 20).unwrap();
1677 assert_eq!(
1678 results.len(),
1679 1,
1680 "only the smoke memory should match: {results:?}"
1681 );
1682 assert_eq!(results[0].memory_id, smoke_id);
1683 assert!(results[0].content.contains("smoke test"));
1684 }
1685
1686 #[test]
1687 fn search_project_compound_query() {
1688 let (_tmp, engine) = open_engine();
1690 let mut writer = engine.writer().unwrap();
1691
1692 engine
1693 .add_document(
1694 &mut writer,
1695 "11111111-1111-1111-1111-111111111111",
1696 "codex",
1697 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1698 &["project_antigravity-project-test".into()],
1699 1700000000,
1700 )
1701 .unwrap();
1702 engine.commit(&mut writer).unwrap();
1703
1704 let results = engine
1705 .search("antigravity antigravity-project-test", 10)
1706 .unwrap();
1707 assert!(
1708 !results.is_empty(),
1709 "project compound query must match the antigravity memory"
1710 );
1711 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1712 }
1713
1714 #[test]
1715 fn or_default_filters_partial_matches_via_coverage() {
1716 let (_tmp, engine) = open_engine();
1717 let mut writer = engine.writer().unwrap();
1718
1719 engine
1720 .add_document(
1721 &mut writer,
1722 "11111111-1111-1111-1111-111111111111",
1723 "codex",
1724 "alpha beta gamma delta",
1725 &[],
1726 1700000000,
1727 )
1728 .unwrap();
1729 engine
1730 .add_document(
1731 &mut writer,
1732 "22222222-2222-2222-2222-222222222222",
1733 "codex",
1734 "alpha only here",
1735 &[],
1736 1700000001,
1737 )
1738 .unwrap();
1739 engine.commit(&mut writer).unwrap();
1740
1741 let results = engine.search("alpha beta gamma", 10).unwrap();
1744 assert_eq!(results.len(), 1);
1745 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1746
1747 let results = engine.search("alpha beta", 10).unwrap();
1749 assert_eq!(results.len(), 2);
1750 }
1751
1752 #[test]
1753 fn coverage_is_case_insensitive() {
1754 let (_tmp, engine) = open_engine();
1755 let mut writer = engine.writer().unwrap();
1756 engine
1757 .add_document(
1758 &mut writer,
1759 "11111111-1111-1111-1111-111111111111",
1760 "codex",
1761 "Smoke Test for wmClient integration",
1762 &[],
1763 1700000000,
1764 )
1765 .unwrap();
1766 engine
1767 .add_document(
1768 &mut writer,
1769 "22222222-2222-2222-2222-222222222222",
1770 "codex",
1771 "test only here",
1772 &[],
1773 1700000001,
1774 )
1775 .unwrap();
1776 engine.commit(&mut writer).unwrap();
1777
1778 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1783 assert_eq!(results.len(), 1);
1784 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1785 }
1786
1787 #[test]
1788 fn coverage_matches_stemmed_variants() {
1789 let (_tmp, engine) = open_engine();
1792 let mut writer = engine.writer().unwrap();
1793 engine
1794 .add_document(
1795 &mut writer,
1796 "11111111-1111-1111-1111-111111111111",
1797 "codex",
1798 "I graduated with a degree in Business Administration",
1799 &[],
1800 1700000000,
1801 )
1802 .unwrap();
1803 engine
1804 .add_document(
1805 &mut writer,
1806 "22222222-2222-2222-2222-222222222222",
1807 "codex",
1808 "degree only here",
1809 &[],
1810 1700000001,
1811 )
1812 .unwrap();
1813 engine.commit(&mut writer).unwrap();
1814
1815 let results = engine.search("graduate degree", 10).unwrap();
1821 assert_eq!(results.len(), 2);
1822 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1823 }
1824
1825 #[test]
1826 fn coverage_normalizes_possessives_and_punctuation() {
1827 let query = "buy sister's birthday gift";
1828 assert_eq!(
1829 query_stem_tokens(query),
1830 ["buy", "sister", "birthday", "gift"]
1831 );
1832 assert_eq!(
1833 count_token_hits("I bought a dress for my sister birthday", query),
1834 2
1835 );
1836 }
1837
1838 #[test]
1839 fn search_stopword_only_query_returns_nothing() {
1840 let (_tmp, engine) = open_engine();
1841 let mut writer = engine.writer().unwrap();
1842 engine
1843 .add_document(
1844 &mut writer,
1845 "11111111-1111-1111-1111-111111111111",
1846 "codex",
1847 "some ordinary text",
1848 &[],
1849 1700000000,
1850 )
1851 .unwrap();
1852 engine.commit(&mut writer).unwrap();
1853
1854 let results = engine.search("the from and or", 10).unwrap();
1855 assert!(results.is_empty());
1856 }
1857
1858 #[test]
1859 fn wildcard_query_doesnt_match_all() {
1860 let (_tmp, engine) = open_engine();
1861 let mut writer = engine.writer().unwrap();
1862
1863 engine
1864 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
1865 .unwrap();
1866 engine
1867 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
1868 .unwrap();
1869 engine.commit(&mut writer).unwrap();
1870
1871 let results = engine.search("*", 10).unwrap();
1873 assert!(
1876 results.is_empty(),
1877 "wildcard query should not match all documents after sanitization"
1878 );
1879 }
1880
1881 #[test]
1882 fn field_syntax_query_doesnt_access_other_fields() {
1883 let (_tmp, engine) = open_engine();
1884 let mut writer = engine.writer().unwrap();
1885
1886 engine
1888 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
1889 .unwrap();
1890 engine.commit(&mut writer).unwrap();
1891
1892 let results = engine.search("galaxy:secret", 10).unwrap();
1894 assert!(
1897 results.is_empty(),
1898 "field syntax injection should not access non-searchable fields"
1899 );
1900 }
1901
1902 #[test]
1903 fn boolean_operator_doesnt_bypass_search() {
1904 let (_tmp, engine) = open_engine();
1905 let mut writer = engine.writer().unwrap();
1906
1907 engine
1908 .add_document(
1909 &mut writer,
1910 "uuid-1",
1911 "codex",
1912 "important secret data",
1913 &[],
1914 1000,
1915 )
1916 .unwrap();
1917 engine.commit(&mut writer).unwrap();
1918
1919 let results = engine.search("AND secret", 10).unwrap();
1923 assert_eq!(results.len(), 1);
1924
1925 let results = engine.search("AND OR NOT", 10).unwrap();
1928 assert!(
1929 results.is_empty(),
1930 "operator-only query must not bypass search"
1931 );
1932 }
1933}