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