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 Self::open_readonly_with_disclosure(path, true)
437 }
438
439 pub fn open_readonly_quiet(path: impl AsRef<Path>) -> Result<Self> {
444 Self::open_readonly_with_disclosure(path, false)
445 }
446
447 fn open_readonly_with_disclosure(path: impl AsRef<Path>, loud: bool) -> Result<Self> {
448 let path = path.as_ref();
449 if loud {
453 tracing::warn!(
454 "read-only search index opened at {} — it will not observe writes made \
455 after this point; restart the read-only server to pick up new memories",
456 path.display()
457 );
458 } else {
459 tracing::debug!(
460 "read-only search index opened at {} (inspection process)",
461 path.display()
462 );
463 }
464 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
465 Self::build_schema();
466 let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
467 let reader = index
468 .reader_builder()
469 .reload_policy(ReloadPolicy::OnCommitWithDelay)
470 .try_into()
471 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
472 Ok(Self {
473 index,
474 reader,
475 writer: Mutex::new(None),
476 field_id,
477 field_galaxy,
478 field_content,
479 field_tags,
480 field_timestamp,
481 health: IndexHealth::default(),
482 schema_migrated,
483 })
484 }
485
486 #[must_use]
492 pub const fn schema_migrated(&self) -> bool {
493 self.schema_migrated
494 }
495
496 #[must_use]
499 pub const fn health(&self) -> &IndexHealth {
500 &self.health
501 }
502
503 pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
509 self.reader
513 .reload()
514 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
515 let searcher = self.reader.searcher();
516 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
517 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
518 let count = searcher
519 .search(&query, &tantivy::collector::Count)
520 .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
521 Ok(count)
522 }
523
524 pub fn indexed_ids_in_galaxy(&self, galaxy: &str) -> Result<std::collections::HashSet<String>> {
531 self.reader
535 .reload()
536 .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
537 let searcher = self.reader.searcher();
538 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
539 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
540 let count = self.count_docs_in_galaxy(galaxy)?;
541 let hits: std::collections::HashSet<tantivy::DocAddress> = searcher
542 .search(&query, &tantivy::collector::DocSetCollector)
543 .map_err(|e| CoreError::Memory(format!("Tantivy indexed_ids: {e}")))?;
544 let mut out = std::collections::HashSet::with_capacity(count);
545 for addr in hits {
546 let doc: TantivyDocument = searcher
547 .doc(addr)
548 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
549 if let Some(id) = doc.get_first(self.field_id).and_then(|v| v.as_str()) {
550 out.insert(id.to_string());
551 }
552 }
553 Ok(out)
554 }
555
556 pub fn is_readonly(&self) -> bool {
558 self.writer.lock().map_or(true, |g| g.is_none())
559 }
560
561 pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
567 let guard = self
568 .writer
569 .lock()
570 .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
571 if guard.is_none() {
572 return Err(CoreError::Memory(
573 "Tantivy writer unavailable: index opened read-only".into(),
574 ));
575 }
576 Ok(guard)
577 }
578
579 pub fn add_document(
586 &self,
587 writer: &mut Option<IndexWriter>,
588 memory_id: &str,
589 galaxy: &str,
590 content: &str,
591 tags: &[String],
592 timestamp: i64,
593 ) -> Result<()> {
594 let writer = writer.as_mut().ok_or_else(|| {
595 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
596 })?;
597 let Some(clean_content) = sanitize_content_for_index(content) else {
598 tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
599 return Ok(());
600 };
601 let tags_str = tags.join(" ");
602 let doc = doc!(
603 self.field_id => memory_id,
604 self.field_galaxy => galaxy,
605 self.field_content => clean_content,
606 self.field_tags => tags_str,
607 self.field_timestamp => timestamp,
608 );
609 match writer.add_document(doc) {
610 Ok(_) => {
611 self.health.record_success();
612 Ok(())
613 }
614 Err(e) => {
615 let msg = format!("Tantivy add_document: {e}");
616 self.health.record_failure(&msg);
617 Err(CoreError::Memory(msg))
618 }
619 }
620 }
621
622 pub fn index_memory(
624 &self,
625 writer: &mut Option<IndexWriter>,
626 mem: &crate::memory::Memory,
627 ) -> Result<()> {
628 self.add_document(
629 writer,
630 &mem.metadata.id.to_string(),
631 mem.metadata.galaxy.db_name(),
632 &mem.content,
633 &mem.metadata.tags,
634 mem.metadata.created_at.timestamp(),
635 )
636 }
637
638 pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
640 let writer = writer.as_mut().ok_or_else(|| {
641 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
642 })?;
643 let term = tantivy::Term::from_field_text(self.field_id, memory_id);
644 writer.delete_term(term);
645 Ok(())
646 }
647
648 pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
653 let writer = writer.as_mut().ok_or_else(|| {
654 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
655 })?;
656 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
657 writer.delete_term(term);
658 Ok(())
659 }
660
661 pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
663 let writer = writer.as_mut().ok_or_else(|| {
664 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
665 })?;
666 writer
667 .commit()
668 .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
669 self.reader
670 .reload()
671 .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
672 Ok(())
673 }
674
675 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
681 let opts = SearchOptions {
682 limit,
683 ..SearchOptions::default()
684 };
685 self.search_opt(query, &opts)
686 }
687
688 pub fn search_in_galaxy(
694 &self,
695 query: &str,
696 galaxy: Option<Galaxy>,
697 limit: usize,
698 ) -> Result<Vec<SearchResult>> {
699 let opts = SearchOptions {
700 limit,
701 galaxy,
702 ..SearchOptions::default()
703 };
704 self.search_opt(query, &opts)
705 }
706
707 pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
724 if opts.limit == 0 {
730 return Err(CoreError::InvalidArgs(
731 "search limit must be >= 1, got: 0".into(),
732 ));
733 }
734 let stripped = strip_stopwords(query);
735 let sanitized = sanitize_tantivy_query(&stripped);
736 if sanitized.trim().is_empty() {
737 return Ok(Vec::new());
738 }
739
740 let searcher = self.reader.searcher();
741
742 let query_parser =
745 QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
746
747 let parsed = parse_query_with_fallback(&query_parser, &sanitized);
748
749 let collector = TopDocs::with_limit(opts.limit).order_by_score();
750
751 let top_docs = searcher
752 .search(&parsed, &collector)
753 .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
754
755 let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
756 let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
757 let relative_floor = opts
758 .relative_floor
759 .map_or(f32::MIN, |ratio| top_score * ratio);
760
761 let query_tokens = query_stem_tokens(&stripped);
765 let coverage_floor = if query_tokens.len() >= 3 { 2 } else { 1 };
766
767 let mut results = Vec::new();
768 for (score, doc_address) in top_docs {
769 if score < absolute_floor || score < relative_floor {
771 continue;
772 }
773
774 let doc: TantivyDocument = searcher
775 .doc(doc_address)
776 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
777
778 let memory_id = doc
779 .get_first(self.field_id)
780 .and_then(|v| v.as_str())
781 .unwrap_or("")
782 .to_string();
783
784 let doc_galaxy = doc
785 .get_first(self.field_galaxy)
786 .and_then(|v| v.as_str())
787 .unwrap_or("")
788 .to_string();
789
790 if let Some(g) = opts.galaxy {
791 if doc_galaxy != g.db_name() {
792 continue;
793 }
794 }
795
796 let content = doc
797 .get_first(self.field_content)
798 .and_then(|v| v.as_str())
799 .unwrap_or("")
800 .to_string();
801
802 if coverage_floor > 1 {
803 let hits = count_token_hits(&content, &stripped);
804 if hits < coverage_floor {
805 continue;
806 }
807 }
808
809 let boosted_score = if query_tokens.is_empty() {
812 score
813 } else {
814 let hits = count_token_hits(&content, &stripped);
815 let ratio = hits as f32 / query_tokens.len() as f32;
816 score * 0.1f32.mul_add(ratio, 1.0)
817 };
818
819 results.push(SearchResult {
820 memory_id,
821 galaxy: doc_galaxy,
822 score: boosted_score,
823 normalized_score: 0.0, content: scrub_text(&content),
825 });
826 }
827
828 results.sort_by(|a, b| {
830 b.score
831 .partial_cmp(&a.score)
832 .unwrap_or(std::cmp::Ordering::Equal)
833 });
834
835 let top_boosted = results.first().map_or(0.0, |r| r.score);
837 for r in &mut results {
838 r.normalized_score = if top_boosted > 0.0 {
839 r.score / top_boosted
840 } else {
841 0.0
842 };
843 }
844
845 Ok(results)
846 }
847
848 pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
850 let results = self.search(query, limit)?;
851 Ok(results
852 .into_iter()
853 .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
854 .collect())
855 }
856}
857
858fn parse_query_with_fallback(parser: &QueryParser, query: &str) -> Box<dyn tantivy::query::Query> {
867 match parser.parse_query(query) {
868 Ok(parsed) => parsed,
869 Err(_) => parser.parse_query_lenient(query).0,
870 }
871}
872
873#[must_use]
890pub fn sanitize_tantivy_query(input: &str) -> String {
891 if input.trim().is_empty() {
893 return String::new();
894 }
895
896 input
897 .split_whitespace()
898 .filter(|term| term.chars().any(char::is_alphanumeric))
899 .map(|term| {
900 if term_needs_quoting(term) {
901 let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
905 format!("\"{escaped}\"")
906 } else {
907 term.to_string()
908 }
909 })
910 .collect::<Vec<_>>()
911 .join(" ")
912}
913
914#[must_use]
916fn term_needs_quoting(term: &str) -> bool {
917 if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
918 return true;
919 }
920 if term == "AND" || term == "OR" || term == "NOT" {
921 return true;
922 }
923 if term.contains("&&") || term.contains("||") {
924 return true;
925 }
926 term.chars().any(|c| {
927 matches!(
928 c,
929 '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
930 )
931 })
932}
933
934#[must_use]
938pub fn strip_stopwords(query: &str) -> String {
939 query
940 .split_whitespace()
941 .filter(|term| !STOPWORDS.contains(&term.to_lowercase().as_str()))
942 .collect::<Vec<_>>()
943 .join(" ")
944}
945
946#[must_use]
950fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
951 stem_tokens(stripped_query)
952}
953
954#[must_use]
958fn stem_tokens(text: &str) -> Vec<String> {
959 let mut tokens: Vec<String> = Vec::new();
960 for term in text
961 .split(|c: char| !c.is_alphanumeric())
962 .filter(|term| term.len() > 1)
963 {
964 let stemmed = simple_stem(&term.to_lowercase());
965 if !tokens.contains(&stemmed) {
966 tokens.push(stemmed);
967 }
968 }
969 tokens
970}
971
972#[must_use]
978fn simple_stem(word: &str) -> String {
979 if word.len() <= 3 {
980 return word.to_string();
981 }
982 for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
984 if let Some(stem) = word.strip_suffix(suffix) {
985 if suffix == "ies" || suffix == "ied" {
987 return format!("{stem}y");
988 }
989 if stem.len() >= 2 {
991 return stem.to_string();
992 }
993 }
994 }
995 word.to_string()
996}
997
998#[must_use]
1002fn count_token_hits(content: &str, stripped_query: &str) -> usize {
1003 let query_tokens = query_stem_tokens(stripped_query);
1004 if query_tokens.is_empty() {
1005 return 0;
1006 }
1007 let content_stems: std::collections::HashSet<String> =
1008 stem_tokens(content).into_iter().collect();
1009 query_tokens
1010 .iter()
1011 .filter(|t| content_stems.contains(*t))
1012 .count()
1013}
1014
1015#[must_use]
1029pub fn printable_ratio(content: &str) -> f32 {
1030 let total = content.chars().count();
1031 if total == 0 {
1032 return 1.0;
1033 }
1034 let printable = content
1035 .chars()
1036 .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
1037 .count();
1038 printable as f32 / total as f32
1039}
1040
1041#[must_use]
1052pub fn sanitize_content_for_index(content: &str) -> Option<String> {
1053 if content.trim().is_empty() {
1054 return None;
1055 }
1056 if content.as_bytes().contains(&0) {
1057 return None;
1058 }
1059 if printable_ratio(content) < MIN_PRINTABLE_RATIO {
1060 return None;
1061 }
1062
1063 let cleaned = scrub_text(content);
1064 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1065 if capped.trim().is_empty() {
1066 None
1067 } else {
1068 Some(capped)
1069 }
1070}
1071
1072#[must_use]
1076pub fn scrub_text(content: &str) -> String {
1077 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1078 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1079 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1080 out.push(' ');
1081 } else {
1082 out.push(c);
1083 }
1084 }
1085 out
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091 use tempfile::tempdir;
1092
1093 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1094 let tmp = tempdir().unwrap();
1095 let engine = SearchEngine::open(tmp.path()).unwrap();
1096 (tmp, engine)
1097 }
1098
1099 #[test]
1103 fn search_rejects_zero_limit_without_panicking() {
1104 let (_tmp, engine) = open_engine();
1105 let err = engine.search("anything", 0).unwrap_err();
1106 assert!(err.to_string().contains("limit"), "{err}");
1107 let err = engine
1108 .search_in_galaxy("anything", Some(Galaxy::Codex), 0)
1109 .unwrap_err();
1110 assert!(err.to_string().contains("limit"), "{err}");
1111 let opts = SearchOptions {
1112 limit: 0,
1113 ..SearchOptions::default()
1114 };
1115 assert!(engine.search_opt("anything", &opts).is_err());
1116 }
1117
1118 fn write_incompatible_index(dir: &Path) {
1121 std::fs::create_dir_all(dir).unwrap();
1122 let mut builder = Schema::builder();
1123 builder.add_text_field("legacy", STRING | STORED);
1124 let schema = builder.build();
1125 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1126 Index::open_or_create(directory, schema).unwrap();
1127 }
1128
1129 #[test]
1130 fn open_migrates_incompatible_schema() {
1131 let tmp = tempdir().unwrap();
1132 let dir = tmp.path().join("tantivy");
1133 write_incompatible_index(&dir);
1134
1135 let engine = SearchEngine::open(&dir).unwrap();
1136 assert!(
1137 engine.schema_migrated(),
1138 "incompatible schema must trigger migration"
1139 );
1140
1141 let backups: Vec<_> = std::fs::read_dir(tmp.path())
1143 .unwrap()
1144 .filter_map(std::result::Result::ok)
1145 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1146 .collect();
1147 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1148
1149 let mut writer = engine.writer().unwrap();
1151 engine
1152 .add_document(
1153 &mut writer,
1154 "33333333-3333-3333-3333-333333333333",
1155 "codex",
1156 "fresh index after migration",
1157 &[],
1158 1700000000,
1159 )
1160 .unwrap();
1161 engine.commit(&mut writer).unwrap();
1162 let results = engine.search("fresh index", 10).unwrap();
1163 assert_eq!(results.len(), 1);
1164 }
1165
1166 #[test]
1167 fn writer_lock_error_names_path_and_hint() {
1168 let err = format_writer_lock_error(
1172 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1173 Path::new("/store/x/tantivy"),
1174 );
1175 assert!(
1176 err.contains("/store/x/tantivy"),
1177 "must name the index path: {err}"
1178 );
1179 assert!(
1180 err.contains("pgrep -af wm"),
1181 "must include the diagnostic hint: {err}"
1182 );
1183 assert!(
1184 err.contains("--readonly"),
1185 "must offer the readonly alternative: {err}"
1186 );
1187
1188 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1190 assert!(other.starts_with("Tantivy writer: disk full"));
1191 assert!(!other.contains("pgrep"));
1192 }
1193
1194 #[test]
1195 fn open_readonly_rejects_incompatible_schema() {
1196 let tmp = tempdir().unwrap();
1197 let dir = tmp.path().join("tantivy");
1198 write_incompatible_index(&dir);
1199
1200 let err = match SearchEngine::open_readonly(&dir) {
1201 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1202 Err(e) => e,
1203 };
1204 assert!(
1205 format!("{err}").contains("wm reindex"),
1206 "read-only mismatch must point at wm reindex, got: {err}"
1207 );
1208
1209 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1211 .unwrap()
1212 .filter_map(std::result::Result::ok)
1213 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1214 .collect();
1215 assert!(siblings.is_empty(), "read-only open must not migrate");
1216 }
1217
1218 #[test]
1219 fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1220 let tmp = tempdir().unwrap();
1221 let dir = tmp.path().join("tantivy");
1222 std::fs::create_dir_all(&dir).unwrap();
1223
1224 let err = match SearchEngine::open_readonly(&dir) {
1225 Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1226 Err(err) => err,
1227 };
1228 assert!(format!("{err}").contains("readonly open-existing"));
1229 assert!(
1230 std::fs::read_dir(&dir).unwrap().next().is_none(),
1231 "readonly open must not materialize Tantivy metadata or segments"
1232 );
1233 }
1234
1235 #[test]
1236 fn reopen_matching_schema_not_migrated() {
1237 let tmp = tempdir().unwrap();
1238 let dir = tmp.path().join("tantivy");
1239 std::fs::create_dir_all(&dir).unwrap();
1240
1241 let first = SearchEngine::open(&dir).unwrap();
1242 assert!(!first.schema_migrated());
1243 drop(first); let second = SearchEngine::open(&dir).unwrap();
1246 assert!(
1247 !second.schema_migrated(),
1248 "matching schema must not migrate"
1249 );
1250 drop(second);
1251
1252 let third = SearchEngine::open_readonly(&dir).unwrap();
1253 assert!(!third.schema_migrated());
1254 }
1255
1256 #[test]
1257 fn index_and_search_basic() {
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 "The Rust programming language is fast and safe",
1267 &["rust".into(), "programming".into()],
1268 1700000000,
1269 )
1270 .unwrap();
1271 engine
1272 .add_document(
1273 &mut writer,
1274 "22222222-2222-2222-2222-222222222222",
1275 "codex",
1276 "Python is great for data science",
1277 &["python".into(), "data".into()],
1278 1700000001,
1279 )
1280 .unwrap();
1281 engine.commit(&mut writer).unwrap();
1282
1283 let results = engine.search("rust", 10).unwrap();
1284 assert!(!results.is_empty());
1285 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1286 }
1287
1288 #[test]
1289 fn search_by_tag() {
1290 let (_tmp, engine) = open_engine();
1291 let mut writer = engine.writer().unwrap();
1292
1293 engine
1294 .add_document(
1295 &mut writer,
1296 "11111111-1111-1111-1111-111111111111",
1297 "codex",
1298 "memory about systems",
1299 &["rust".into()],
1300 1700000000,
1301 )
1302 .unwrap();
1303 engine
1304 .add_document(
1305 &mut writer,
1306 "22222222-2222-2222-2222-222222222222",
1307 "codex",
1308 "memory about cooking",
1309 &["food".into()],
1310 1700000001,
1311 )
1312 .unwrap();
1313 engine.commit(&mut writer).unwrap();
1314
1315 let results = engine.search("rust", 10).unwrap();
1316 assert_eq!(results.len(), 1);
1317 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1318 }
1319
1320 #[test]
1321 fn search_filtered_by_galaxy() {
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 "important knowledge",
1331 &[],
1332 1700000000,
1333 )
1334 .unwrap();
1335 engine
1336 .add_document(
1337 &mut writer,
1338 "22222222-2222-2222-2222-222222222222",
1339 "research",
1340 "important findings",
1341 &[],
1342 1700000001,
1343 )
1344 .unwrap();
1345 engine.commit(&mut writer).unwrap();
1346
1347 let results = engine
1348 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1349 .unwrap();
1350 assert_eq!(results.len(), 1);
1351 assert_eq!(results[0].galaxy, "codex");
1352 }
1353
1354 #[test]
1355 fn delete_document_from_index() {
1356 let (_tmp, engine) = open_engine();
1357 let mut writer = engine.writer().unwrap();
1358
1359 engine
1360 .add_document(
1361 &mut writer,
1362 "11111111-1111-1111-1111-111111111111",
1363 "codex",
1364 "deletable content",
1365 &[],
1366 1700000000,
1367 )
1368 .unwrap();
1369 engine.commit(&mut writer).unwrap();
1370
1371 let results = engine.search("deletable", 10).unwrap();
1372 assert_eq!(results.len(), 1);
1373
1374 engine
1375 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1376 .unwrap();
1377 engine.commit(&mut writer).unwrap();
1378
1379 let results = engine.search("deletable", 10).unwrap();
1380 assert_eq!(results.len(), 0);
1381 }
1382
1383 #[test]
1384 fn search_empty_index() {
1385 let (_tmp, engine) = open_engine();
1386 let results = engine.search("anything", 10).unwrap();
1387 assert!(results.is_empty());
1388 }
1389
1390 #[test]
1391 fn search_ids_returns_uuids() {
1392 let (_tmp, engine) = open_engine();
1393 let mut writer = engine.writer().unwrap();
1394
1395 engine
1396 .add_document(
1397 &mut writer,
1398 "11111111-1111-1111-1111-111111111111",
1399 "codex",
1400 "unique content about rust",
1401 &[],
1402 1700000000,
1403 )
1404 .unwrap();
1405 engine.commit(&mut writer).unwrap();
1406
1407 let ids = engine.search_ids("rust", 10).unwrap();
1408 assert_eq!(ids.len(), 1);
1409 assert_eq!(
1410 ids[0],
1411 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1412 );
1413 }
1414
1415 #[test]
1418 fn sanitize_leaves_plain_terms_unquoted() {
1419 let result = sanitize_tantivy_query("hello world");
1420 assert_eq!(result, "hello world");
1421 }
1422
1423 #[test]
1424 fn sanitize_drops_punct_only_terms() {
1425 let result = sanitize_tantivy_query("*");
1426 assert_eq!(result, "");
1427 }
1429
1430 #[test]
1431 fn sanitize_escapes_boolean_operators() {
1432 let result = sanitize_tantivy_query("NOT secret");
1433 assert_eq!(result, "\"NOT\" secret");
1434 }
1435
1436 #[test]
1437 fn sanitize_escapes_field_syntax() {
1438 let result = sanitize_tantivy_query("content:secret");
1439 assert_eq!(result, "\"content:secret\"");
1440 }
1441
1442 #[test]
1443 fn sanitize_escapes_quotes() {
1444 let result = sanitize_tantivy_query("test\"injection");
1445 assert!(
1446 result.contains("\\\""),
1447 "embedded quotes should be escaped: {result}"
1448 );
1449 }
1450
1451 #[test]
1452 fn sanitize_escapes_trailing_backslash_token() {
1453 let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1456 assert_eq!(
1457 result, "\"C:\\\\Users\\\\temp\\\\\"",
1458 "backslashes must be doubled inside quoted terms"
1459 );
1460 }
1461
1462 #[test]
1463 fn lenient_fallback_never_fails_on_malformed_input() {
1464 let (_tmp, engine) = open_engine();
1465 let parser =
1466 QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1467 let searcher = engine.reader.searcher();
1468 let collector = TopDocs::with_limit(1).order_by_score();
1469 for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1470 let parsed = parse_query_with_fallback(&parser, malformed);
1471 searcher
1472 .search(&parsed, &collector)
1473 .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1474 }
1475 }
1476
1477 #[test]
1478 fn sanitize_empty_returns_empty() {
1479 assert_eq!(sanitize_tantivy_query(""), "");
1480 assert_eq!(sanitize_tantivy_query(" "), "");
1481 }
1482
1483 #[test]
1484 fn sanitize_preserves_alphanumeric() {
1485 let result = sanitize_tantivy_query("rust programming 2024");
1486 assert_eq!(result, "rust programming 2024");
1487 }
1488
1489 #[test]
1490 fn sanitize_preserves_hyphenated_compounds() {
1491 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1492 assert_eq!(result, "antigravity antigravity-project-test");
1493 }
1494
1495 #[test]
1498 fn strip_stopwords_removes_common_words() {
1499 assert_eq!(
1500 strip_stopwords("smoke test from wmClient"),
1501 "smoke test wmClient"
1502 );
1503 assert_eq!(strip_stopwords("the from and or"), "");
1504 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1505 assert_eq!(strip_stopwords(""), "");
1506 }
1507
1508 #[test]
1509 fn strip_stopwords_is_case_insensitive() {
1510 assert_eq!(strip_stopwords("FROM The And"), "");
1511 }
1512
1513 #[test]
1516 fn sanitize_content_skips_null_bytes() {
1517 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1518 assert!(sanitize_content_for_index(content).is_none());
1519 }
1520
1521 #[test]
1522 fn sanitize_content_skips_low_printable_ratio() {
1523 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1525 assert!(sanitize_content_for_index(content).is_none());
1526 }
1527
1528 #[test]
1529 fn sanitize_content_accepts_code_and_formatting_heavy_text() {
1530 let hex_list = "Casper\n#ACBFCD\n\nPickled Bluewood\n#324558\n\nComet\n#545B70\n";
1536 assert!(sanitize_content_for_index(hex_list).is_some());
1537 let html = "Sure, here's how:\n```html\n<!DOCTYPE html>\n<html>\n <body>\n \n \n </body>\n</html>\n```";
1538 assert!(sanitize_content_for_index(html).is_some());
1539 let binary = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1541 assert!(sanitize_content_for_index(binary).is_none());
1542 }
1543
1544 #[test]
1545 fn printable_ratio_treats_line_breaks_as_printable() {
1546 assert!((printable_ratio("a\nb\tc\rd") - 1.0).abs() < f32::EPSILON);
1547 assert!(printable_ratio("\u{01}\u{02}\u{03}\u{04}\u{05}hello") < 0.9);
1548 assert!((printable_ratio("") - 1.0).abs() < f32::EPSILON);
1549 }
1550
1551 #[test]
1552 fn sanitize_content_scrubs_and_caps() {
1553 let content = "clean text\u{01}with one control char";
1555 let cleaned = sanitize_content_for_index(content).unwrap();
1556 assert!(!cleaned.contains('\u{01}'));
1557 assert!(cleaned.starts_with("clean text with one control char"));
1558
1559 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1560 let capped = sanitize_content_for_index(&long).unwrap();
1561 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1562 }
1563
1564 #[test]
1565 fn sanitize_content_skips_empty() {
1566 assert!(sanitize_content_for_index("").is_none());
1567 assert!(sanitize_content_for_index(" ").is_none());
1568 }
1569
1570 #[test]
1571 fn scrub_text_replaces_control_chars() {
1572 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1573 assert_eq!(result, "a b\nc\td e");
1574 }
1575
1576 #[test]
1577 fn add_document_skips_binary_content() {
1578 let (_tmp, engine) = open_engine();
1579 let mut writer = engine.writer().unwrap();
1580
1581 engine
1582 .add_document(
1583 &mut writer,
1584 "11111111-1111-1111-1111-111111111111",
1585 "codex",
1586 "\u{00}\u{01}\u{02}raw serialized bytes",
1587 &[],
1588 1700000000,
1589 )
1590 .unwrap();
1591 engine
1592 .add_document(
1593 &mut writer,
1594 "22222222-2222-2222-2222-222222222222",
1595 "codex",
1596 "clean searchable text",
1597 &[],
1598 1700000001,
1599 )
1600 .unwrap();
1601 engine.commit(&mut writer).unwrap();
1602
1603 let results = engine.search("serialized", 10).unwrap();
1605 assert!(results.is_empty(), "binary content must not be indexed");
1606
1607 let results = engine.search("clean", 10).unwrap();
1608 assert_eq!(results.len(), 1);
1609 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1610 }
1611
1612 fn index_alpha_pair(engine: &SearchEngine) {
1615 let mut writer = engine.writer().unwrap();
1616 engine
1619 .add_document(
1620 &mut writer,
1621 "11111111-1111-1111-1111-111111111111",
1622 "codex",
1623 "alpha",
1624 &[],
1625 1700000000,
1626 )
1627 .unwrap();
1628 let filler = format!("alpha {}", "zzz ".repeat(400));
1629 engine
1630 .add_document(
1631 &mut writer,
1632 "22222222-2222-2222-2222-222222222222",
1633 "codex",
1634 &filler,
1635 &[],
1636 1700000001,
1637 )
1638 .unwrap();
1639 engine.commit(&mut writer).unwrap();
1640 }
1641
1642 #[test]
1643 fn search_absolute_min_score_filters_weak_matches() {
1644 let (_tmp, engine) = open_engine();
1645 index_alpha_pair(&engine);
1646
1647 let base = engine.search("alpha", 10).unwrap();
1648 assert_eq!(base.len(), 2);
1649 let (hi, lo) = if base[0].score >= base[1].score {
1650 (base[0].score, base[1].score)
1651 } else {
1652 (base[1].score, base[0].score)
1653 };
1654 assert!(
1655 hi > lo,
1656 "short doc should outscore long doc (hi={hi}, lo={lo})"
1657 );
1658 let mid = f32::midpoint(hi, lo);
1659
1660 let opts = SearchOptions {
1661 limit: 10,
1662 min_score: Some(mid),
1663 ..SearchOptions::default()
1664 };
1665 let filtered = engine.search_opt("alpha", &opts).unwrap();
1666 assert_eq!(filtered.len(), 1);
1667 assert!((filtered[0].score - hi).abs() < 1e-3);
1668 }
1669
1670 #[test]
1671 fn search_relative_floor_filters_weak_matches() {
1672 let (_tmp, engine) = open_engine();
1673 index_alpha_pair(&engine);
1674
1675 let opts = SearchOptions {
1676 limit: 10,
1677 relative_floor: Some(0.5),
1678 ..SearchOptions::default()
1679 };
1680 let filtered = engine.search_opt("alpha", &opts).unwrap();
1681 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1682 assert_eq!(
1683 filtered[0].memory_id,
1684 "11111111-1111-1111-1111-111111111111"
1685 );
1686 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1687 }
1688
1689 #[test]
1690 fn search_all_results_normalized() {
1691 let (_tmp, engine) = open_engine();
1692 index_alpha_pair(&engine);
1693
1694 let results = engine.search("alpha", 10).unwrap();
1695 assert_eq!(results.len(), 2);
1696 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1697 for r in &results[1..] {
1698 assert!(r.normalized_score <= 1.0);
1699 assert!(r.normalized_score > 0.0);
1700 }
1701 }
1702
1703 #[test]
1704 fn search_stemming_matches_morphological_variants() {
1705 let (_tmp, engine) = open_engine();
1708 let mut writer = engine.writer().unwrap();
1709
1710 engine
1711 .add_document(
1712 &mut writer,
1713 "11111111-1111-1111-1111-111111111111",
1714 "codex",
1715 "I graduated with a degree in Business Administration",
1716 &[],
1717 1700000000,
1718 )
1719 .unwrap();
1720 engine.commit(&mut writer).unwrap();
1721
1722 let results = engine.search("graduate", 10).unwrap();
1724 assert_eq!(results.len(), 1);
1725 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1726
1727 let results = engine.search("degrees", 10).unwrap();
1729 assert_eq!(results.len(), 1);
1730 }
1731
1732 #[test]
1733 fn search_incident_query_returns_only_relevant() {
1734 let (_tmp, engine) = open_engine();
1737 let mut writer = engine.writer().unwrap();
1738
1739 let smoke_id = "11111111-1111-1111-1111-111111111111";
1740 engine
1741 .add_document(
1742 &mut writer,
1743 smoke_id,
1744 "codex",
1745 "smoke test from wmClient: verify recall works",
1746 &[],
1747 1700000000,
1748 )
1749 .unwrap();
1750 let unrelated = [
1751 "NES Evolution and Impact: a history of the console wars",
1752 "Insights on The Gateless Gate: koans and zen practice",
1753 "What the tweet is really saying: a thread analysis",
1754 ];
1755 for (i, content) in (1i64..).zip(unrelated.iter()) {
1756 engine
1757 .add_document(
1758 &mut writer,
1759 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1760 "codex",
1761 content,
1762 &[],
1763 1700000000 + i,
1764 )
1765 .unwrap();
1766 }
1767 engine.commit(&mut writer).unwrap();
1768
1769 let results = engine.search("smoke test from wmClient", 20).unwrap();
1770 assert_eq!(
1771 results.len(),
1772 1,
1773 "only the smoke memory should match: {results:?}"
1774 );
1775 assert_eq!(results[0].memory_id, smoke_id);
1776 assert!(results[0].content.contains("smoke test"));
1777 }
1778
1779 #[test]
1780 fn search_project_compound_query() {
1781 let (_tmp, engine) = open_engine();
1783 let mut writer = engine.writer().unwrap();
1784
1785 engine
1786 .add_document(
1787 &mut writer,
1788 "11111111-1111-1111-1111-111111111111",
1789 "codex",
1790 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1791 &["project_antigravity-project-test".into()],
1792 1700000000,
1793 )
1794 .unwrap();
1795 engine.commit(&mut writer).unwrap();
1796
1797 let results = engine
1798 .search("antigravity antigravity-project-test", 10)
1799 .unwrap();
1800 assert!(
1801 !results.is_empty(),
1802 "project compound query must match the antigravity memory"
1803 );
1804 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1805 }
1806
1807 #[test]
1808 fn or_default_filters_partial_matches_via_coverage() {
1809 let (_tmp, engine) = open_engine();
1810 let mut writer = engine.writer().unwrap();
1811
1812 engine
1813 .add_document(
1814 &mut writer,
1815 "11111111-1111-1111-1111-111111111111",
1816 "codex",
1817 "alpha beta gamma delta",
1818 &[],
1819 1700000000,
1820 )
1821 .unwrap();
1822 engine
1823 .add_document(
1824 &mut writer,
1825 "22222222-2222-2222-2222-222222222222",
1826 "codex",
1827 "alpha only here",
1828 &[],
1829 1700000001,
1830 )
1831 .unwrap();
1832 engine.commit(&mut writer).unwrap();
1833
1834 let results = engine.search("alpha beta gamma", 10).unwrap();
1837 assert_eq!(results.len(), 1);
1838 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1839
1840 let results = engine.search("alpha beta", 10).unwrap();
1842 assert_eq!(results.len(), 2);
1843 }
1844
1845 #[test]
1846 fn coverage_is_case_insensitive() {
1847 let (_tmp, engine) = open_engine();
1848 let mut writer = engine.writer().unwrap();
1849 engine
1850 .add_document(
1851 &mut writer,
1852 "11111111-1111-1111-1111-111111111111",
1853 "codex",
1854 "Smoke Test for wmClient integration",
1855 &[],
1856 1700000000,
1857 )
1858 .unwrap();
1859 engine
1860 .add_document(
1861 &mut writer,
1862 "22222222-2222-2222-2222-222222222222",
1863 "codex",
1864 "test only here",
1865 &[],
1866 1700000001,
1867 )
1868 .unwrap();
1869 engine.commit(&mut writer).unwrap();
1870
1871 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1876 assert_eq!(results.len(), 1);
1877 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1878 }
1879
1880 #[test]
1881 fn coverage_matches_stemmed_variants() {
1882 let (_tmp, engine) = open_engine();
1885 let mut writer = engine.writer().unwrap();
1886 engine
1887 .add_document(
1888 &mut writer,
1889 "11111111-1111-1111-1111-111111111111",
1890 "codex",
1891 "I graduated with a degree in Business Administration",
1892 &[],
1893 1700000000,
1894 )
1895 .unwrap();
1896 engine
1897 .add_document(
1898 &mut writer,
1899 "22222222-2222-2222-2222-222222222222",
1900 "codex",
1901 "degree only here",
1902 &[],
1903 1700000001,
1904 )
1905 .unwrap();
1906 engine.commit(&mut writer).unwrap();
1907
1908 let results = engine.search("graduate degree", 10).unwrap();
1914 assert_eq!(results.len(), 2);
1915 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1916 }
1917
1918 #[test]
1919 fn coverage_normalizes_possessives_and_punctuation() {
1920 let query = "buy sister's birthday gift";
1921 assert_eq!(
1922 query_stem_tokens(query),
1923 ["buy", "sister", "birthday", "gift"]
1924 );
1925 assert_eq!(
1926 count_token_hits("I bought a dress for my sister birthday", query),
1927 2
1928 );
1929 }
1930
1931 #[test]
1932 fn search_stopword_only_query_returns_nothing() {
1933 let (_tmp, engine) = open_engine();
1934 let mut writer = engine.writer().unwrap();
1935 engine
1936 .add_document(
1937 &mut writer,
1938 "11111111-1111-1111-1111-111111111111",
1939 "codex",
1940 "some ordinary text",
1941 &[],
1942 1700000000,
1943 )
1944 .unwrap();
1945 engine.commit(&mut writer).unwrap();
1946
1947 let results = engine.search("the from and or", 10).unwrap();
1948 assert!(results.is_empty());
1949 }
1950
1951 #[test]
1952 fn wildcard_query_doesnt_match_all() {
1953 let (_tmp, engine) = open_engine();
1954 let mut writer = engine.writer().unwrap();
1955
1956 engine
1957 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
1958 .unwrap();
1959 engine
1960 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
1961 .unwrap();
1962 engine.commit(&mut writer).unwrap();
1963
1964 let results = engine.search("*", 10).unwrap();
1966 assert!(
1969 results.is_empty(),
1970 "wildcard query should not match all documents after sanitization"
1971 );
1972 }
1973
1974 #[test]
1975 fn field_syntax_query_doesnt_access_other_fields() {
1976 let (_tmp, engine) = open_engine();
1977 let mut writer = engine.writer().unwrap();
1978
1979 engine
1981 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
1982 .unwrap();
1983 engine.commit(&mut writer).unwrap();
1984
1985 let results = engine.search("galaxy:secret", 10).unwrap();
1987 assert!(
1990 results.is_empty(),
1991 "field syntax injection should not access non-searchable fields"
1992 );
1993 }
1994
1995 #[test]
1996 fn boolean_operator_doesnt_bypass_search() {
1997 let (_tmp, engine) = open_engine();
1998 let mut writer = engine.writer().unwrap();
1999
2000 engine
2001 .add_document(
2002 &mut writer,
2003 "uuid-1",
2004 "codex",
2005 "important secret data",
2006 &[],
2007 1000,
2008 )
2009 .unwrap();
2010 engine.commit(&mut writer).unwrap();
2011
2012 let results = engine.search("AND secret", 10).unwrap();
2016 assert_eq!(results.len(), 1);
2017
2018 let results = engine.search("AND OR NOT", 10).unwrap();
2021 assert!(
2022 results.is_empty(),
2023 "operator-only query must not bypass search"
2024 );
2025 }
2026}