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, Term,
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);
768 let present_tokens = query_tokens
769 .iter()
770 .filter(|token| {
771 searcher
772 .doc_freq(&Term::from_field_text(self.field_content, token))
773 .unwrap_or(0)
774 > 0
775 })
776 .count();
777 let coverage_floor = if query_tokens.len() >= 3 && present_tokens >= 2 {
778 2
779 } else {
780 1
781 };
782
783 let mut results = Vec::new();
784 for (score, doc_address) in top_docs {
785 if score < absolute_floor || score < relative_floor {
787 continue;
788 }
789
790 let doc: TantivyDocument = searcher
791 .doc(doc_address)
792 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
793
794 let memory_id = doc
795 .get_first(self.field_id)
796 .and_then(|v| v.as_str())
797 .unwrap_or("")
798 .to_string();
799
800 let doc_galaxy = doc
801 .get_first(self.field_galaxy)
802 .and_then(|v| v.as_str())
803 .unwrap_or("")
804 .to_string();
805
806 if let Some(g) = opts.galaxy {
807 if doc_galaxy != g.db_name() {
808 continue;
809 }
810 }
811
812 let content = doc
813 .get_first(self.field_content)
814 .and_then(|v| v.as_str())
815 .unwrap_or("")
816 .to_string();
817
818 if coverage_floor > 1 {
819 let hits = count_token_hits(&content, &stripped);
820 if hits < coverage_floor {
821 continue;
822 }
823 }
824
825 let boosted_score = if query_tokens.is_empty() {
828 score
829 } else {
830 let hits = count_token_hits(&content, &stripped);
831 let ratio = hits as f32 / query_tokens.len() as f32;
832 score * 0.1f32.mul_add(ratio, 1.0)
833 };
834
835 results.push(SearchResult {
836 memory_id,
837 galaxy: doc_galaxy,
838 score: boosted_score,
839 normalized_score: 0.0, content: scrub_text(&content),
841 });
842 }
843
844 results.sort_by(|a, b| {
846 b.score
847 .partial_cmp(&a.score)
848 .unwrap_or(std::cmp::Ordering::Equal)
849 });
850
851 let top_boosted = results.first().map_or(0.0, |r| r.score);
853 for r in &mut results {
854 r.normalized_score = if top_boosted > 0.0 {
855 r.score / top_boosted
856 } else {
857 0.0
858 };
859 }
860
861 Ok(results)
862 }
863
864 pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
866 let results = self.search(query, limit)?;
867 Ok(results
868 .into_iter()
869 .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
870 .collect())
871 }
872}
873
874fn parse_query_with_fallback(parser: &QueryParser, query: &str) -> Box<dyn tantivy::query::Query> {
883 match parser.parse_query(query) {
884 Ok(parsed) => parsed,
885 Err(_) => parser.parse_query_lenient(query).0,
886 }
887}
888
889#[must_use]
906pub fn sanitize_tantivy_query(input: &str) -> String {
907 if input.trim().is_empty() {
909 return String::new();
910 }
911
912 input
913 .split_whitespace()
914 .filter(|term| term.chars().any(char::is_alphanumeric))
915 .map(|term| {
916 if term_needs_quoting(term) {
917 let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
921 format!("\"{escaped}\"")
922 } else {
923 term.to_string()
924 }
925 })
926 .collect::<Vec<_>>()
927 .join(" ")
928}
929
930#[must_use]
932fn term_needs_quoting(term: &str) -> bool {
933 if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
934 return true;
935 }
936 if term == "AND" || term == "OR" || term == "NOT" {
937 return true;
938 }
939 if term.contains("&&") || term.contains("||") {
940 return true;
941 }
942 term.chars().any(|c| {
943 matches!(
944 c,
945 '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
946 )
947 })
948}
949
950#[must_use]
956pub fn strip_stopwords(query: &str) -> String {
957 query
958 .split_whitespace()
959 .filter(|term| {
960 let normalized: String = term
961 .chars()
962 .filter(|character| character.is_alphanumeric())
963 .collect::<String>()
964 .to_lowercase();
965 !normalized.is_empty() && !STOPWORDS.contains(&normalized.as_str())
966 })
967 .collect::<Vec<_>>()
968 .join(" ")
969}
970
971#[must_use]
975fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
976 stem_tokens(stripped_query)
977}
978
979#[must_use]
983fn stem_tokens(text: &str) -> Vec<String> {
984 let mut tokens: Vec<String> = Vec::new();
985 for term in text
986 .split(|c: char| !c.is_alphanumeric())
987 .filter(|term| term.len() > 1)
988 {
989 let stemmed = simple_stem(&term.to_lowercase());
990 if !tokens.contains(&stemmed) {
991 tokens.push(stemmed);
992 }
993 }
994 tokens
995}
996
997#[must_use]
1003fn simple_stem(word: &str) -> String {
1004 if word.len() <= 3 {
1005 return word.to_string();
1006 }
1007 for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
1009 if let Some(stem) = word.strip_suffix(suffix) {
1010 if suffix == "ies" || suffix == "ied" {
1012 return format!("{stem}y");
1013 }
1014 if stem.len() >= 2 {
1016 return stem.to_string();
1017 }
1018 }
1019 }
1020 word.to_string()
1021}
1022
1023#[must_use]
1027fn count_token_hits(content: &str, stripped_query: &str) -> usize {
1028 let query_tokens = query_stem_tokens(stripped_query);
1029 if query_tokens.is_empty() {
1030 return 0;
1031 }
1032 let content_stems: std::collections::HashSet<String> =
1033 stem_tokens(content).into_iter().collect();
1034 query_tokens
1035 .iter()
1036 .filter(|t| content_stems.contains(*t))
1037 .count()
1038}
1039
1040#[must_use]
1054pub fn printable_ratio(content: &str) -> f32 {
1055 let total = content.chars().count();
1056 if total == 0 {
1057 return 1.0;
1058 }
1059 let printable = content
1060 .chars()
1061 .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
1062 .count();
1063 printable as f32 / total as f32
1064}
1065
1066#[must_use]
1077pub fn sanitize_content_for_index(content: &str) -> Option<String> {
1078 if content.trim().is_empty() {
1079 return None;
1080 }
1081 if content.as_bytes().contains(&0) {
1082 return None;
1083 }
1084 if printable_ratio(content) < MIN_PRINTABLE_RATIO {
1085 return None;
1086 }
1087
1088 let cleaned = scrub_text(content);
1089 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1090 if capped.trim().is_empty() {
1091 None
1092 } else {
1093 Some(capped)
1094 }
1095}
1096
1097#[must_use]
1101pub fn scrub_text(content: &str) -> String {
1102 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1103 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1104 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1105 out.push(' ');
1106 } else {
1107 out.push(c);
1108 }
1109 }
1110 out
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use tempfile::tempdir;
1117
1118 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1119 let tmp = tempdir().unwrap();
1120 let engine = SearchEngine::open(tmp.path()).unwrap();
1121 (tmp, engine)
1122 }
1123
1124 #[test]
1128 fn search_rejects_zero_limit_without_panicking() {
1129 let (_tmp, engine) = open_engine();
1130 let err = engine.search("anything", 0).unwrap_err();
1131 assert!(err.to_string().contains("limit"), "{err}");
1132 let err = engine
1133 .search_in_galaxy("anything", Some(Galaxy::Codex), 0)
1134 .unwrap_err();
1135 assert!(err.to_string().contains("limit"), "{err}");
1136 let opts = SearchOptions {
1137 limit: 0,
1138 ..SearchOptions::default()
1139 };
1140 assert!(engine.search_opt("anything", &opts).is_err());
1141 }
1142
1143 fn write_incompatible_index(dir: &Path) {
1146 std::fs::create_dir_all(dir).unwrap();
1147 let mut builder = Schema::builder();
1148 builder.add_text_field("legacy", STRING | STORED);
1149 let schema = builder.build();
1150 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1151 Index::open_or_create(directory, schema).unwrap();
1152 }
1153
1154 #[test]
1155 fn open_migrates_incompatible_schema() {
1156 let tmp = tempdir().unwrap();
1157 let dir = tmp.path().join("tantivy");
1158 write_incompatible_index(&dir);
1159
1160 let engine = SearchEngine::open(&dir).unwrap();
1161 assert!(
1162 engine.schema_migrated(),
1163 "incompatible schema must trigger migration"
1164 );
1165
1166 let backups: Vec<_> = std::fs::read_dir(tmp.path())
1168 .unwrap()
1169 .filter_map(std::result::Result::ok)
1170 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1171 .collect();
1172 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1173
1174 let mut writer = engine.writer().unwrap();
1176 engine
1177 .add_document(
1178 &mut writer,
1179 "33333333-3333-3333-3333-333333333333",
1180 "codex",
1181 "fresh index after migration",
1182 &[],
1183 1700000000,
1184 )
1185 .unwrap();
1186 engine.commit(&mut writer).unwrap();
1187 let results = engine.search("fresh index", 10).unwrap();
1188 assert_eq!(results.len(), 1);
1189 }
1190
1191 #[test]
1192 fn writer_lock_error_names_path_and_hint() {
1193 let err = format_writer_lock_error(
1197 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1198 Path::new("/store/x/tantivy"),
1199 );
1200 assert!(
1201 err.contains("/store/x/tantivy"),
1202 "must name the index path: {err}"
1203 );
1204 assert!(
1205 err.contains("pgrep -af wm"),
1206 "must include the diagnostic hint: {err}"
1207 );
1208 assert!(
1209 err.contains("--readonly"),
1210 "must offer the readonly alternative: {err}"
1211 );
1212
1213 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1215 assert!(other.starts_with("Tantivy writer: disk full"));
1216 assert!(!other.contains("pgrep"));
1217 }
1218
1219 #[test]
1220 fn open_readonly_rejects_incompatible_schema() {
1221 let tmp = tempdir().unwrap();
1222 let dir = tmp.path().join("tantivy");
1223 write_incompatible_index(&dir);
1224
1225 let err = match SearchEngine::open_readonly(&dir) {
1226 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1227 Err(e) => e,
1228 };
1229 assert!(
1230 format!("{err}").contains("wm reindex"),
1231 "read-only mismatch must point at wm reindex, got: {err}"
1232 );
1233
1234 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1236 .unwrap()
1237 .filter_map(std::result::Result::ok)
1238 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1239 .collect();
1240 assert!(siblings.is_empty(), "read-only open must not migrate");
1241 }
1242
1243 #[test]
1244 fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1245 let tmp = tempdir().unwrap();
1246 let dir = tmp.path().join("tantivy");
1247 std::fs::create_dir_all(&dir).unwrap();
1248
1249 let err = match SearchEngine::open_readonly(&dir) {
1250 Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1251 Err(err) => err,
1252 };
1253 assert!(format!("{err}").contains("readonly open-existing"));
1254 assert!(
1255 std::fs::read_dir(&dir).unwrap().next().is_none(),
1256 "readonly open must not materialize Tantivy metadata or segments"
1257 );
1258 }
1259
1260 #[test]
1261 fn reopen_matching_schema_not_migrated() {
1262 let tmp = tempdir().unwrap();
1263 let dir = tmp.path().join("tantivy");
1264 std::fs::create_dir_all(&dir).unwrap();
1265
1266 let first = SearchEngine::open(&dir).unwrap();
1267 assert!(!first.schema_migrated());
1268 drop(first); let second = SearchEngine::open(&dir).unwrap();
1271 assert!(
1272 !second.schema_migrated(),
1273 "matching schema must not migrate"
1274 );
1275 drop(second);
1276
1277 let third = SearchEngine::open_readonly(&dir).unwrap();
1278 assert!(!third.schema_migrated());
1279 }
1280
1281 #[test]
1282 fn index_and_search_basic() {
1283 let (_tmp, engine) = open_engine();
1284 let mut writer = engine.writer().unwrap();
1285
1286 engine
1287 .add_document(
1288 &mut writer,
1289 "11111111-1111-1111-1111-111111111111",
1290 "codex",
1291 "The Rust programming language is fast and safe",
1292 &["rust".into(), "programming".into()],
1293 1700000000,
1294 )
1295 .unwrap();
1296 engine
1297 .add_document(
1298 &mut writer,
1299 "22222222-2222-2222-2222-222222222222",
1300 "codex",
1301 "Python is great for data science",
1302 &["python".into(), "data".into()],
1303 1700000001,
1304 )
1305 .unwrap();
1306 engine.commit(&mut writer).unwrap();
1307
1308 let results = engine.search("rust", 10).unwrap();
1309 assert!(!results.is_empty());
1310 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1311 }
1312
1313 #[test]
1314 fn search_by_tag() {
1315 let (_tmp, engine) = open_engine();
1316 let mut writer = engine.writer().unwrap();
1317
1318 engine
1319 .add_document(
1320 &mut writer,
1321 "11111111-1111-1111-1111-111111111111",
1322 "codex",
1323 "memory about systems",
1324 &["rust".into()],
1325 1700000000,
1326 )
1327 .unwrap();
1328 engine
1329 .add_document(
1330 &mut writer,
1331 "22222222-2222-2222-2222-222222222222",
1332 "codex",
1333 "memory about cooking",
1334 &["food".into()],
1335 1700000001,
1336 )
1337 .unwrap();
1338 engine.commit(&mut writer).unwrap();
1339
1340 let results = engine.search("rust", 10).unwrap();
1341 assert_eq!(results.len(), 1);
1342 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1343 }
1344
1345 #[test]
1346 fn search_filtered_by_galaxy() {
1347 let (_tmp, engine) = open_engine();
1348 let mut writer = engine.writer().unwrap();
1349
1350 engine
1351 .add_document(
1352 &mut writer,
1353 "11111111-1111-1111-1111-111111111111",
1354 "codex",
1355 "important knowledge",
1356 &[],
1357 1700000000,
1358 )
1359 .unwrap();
1360 engine
1361 .add_document(
1362 &mut writer,
1363 "22222222-2222-2222-2222-222222222222",
1364 "research",
1365 "important findings",
1366 &[],
1367 1700000001,
1368 )
1369 .unwrap();
1370 engine.commit(&mut writer).unwrap();
1371
1372 let results = engine
1373 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1374 .unwrap();
1375 assert_eq!(results.len(), 1);
1376 assert_eq!(results[0].galaxy, "codex");
1377 }
1378
1379 #[test]
1380 fn delete_document_from_index() {
1381 let (_tmp, engine) = open_engine();
1382 let mut writer = engine.writer().unwrap();
1383
1384 engine
1385 .add_document(
1386 &mut writer,
1387 "11111111-1111-1111-1111-111111111111",
1388 "codex",
1389 "deletable content",
1390 &[],
1391 1700000000,
1392 )
1393 .unwrap();
1394 engine.commit(&mut writer).unwrap();
1395
1396 let results = engine.search("deletable", 10).unwrap();
1397 assert_eq!(results.len(), 1);
1398
1399 engine
1400 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1401 .unwrap();
1402 engine.commit(&mut writer).unwrap();
1403
1404 let results = engine.search("deletable", 10).unwrap();
1405 assert_eq!(results.len(), 0);
1406 }
1407
1408 #[test]
1409 fn search_empty_index() {
1410 let (_tmp, engine) = open_engine();
1411 let results = engine.search("anything", 10).unwrap();
1412 assert!(results.is_empty());
1413 }
1414
1415 #[test]
1416 fn search_ids_returns_uuids() {
1417 let (_tmp, engine) = open_engine();
1418 let mut writer = engine.writer().unwrap();
1419
1420 engine
1421 .add_document(
1422 &mut writer,
1423 "11111111-1111-1111-1111-111111111111",
1424 "codex",
1425 "unique content about rust",
1426 &[],
1427 1700000000,
1428 )
1429 .unwrap();
1430 engine.commit(&mut writer).unwrap();
1431
1432 let ids = engine.search_ids("rust", 10).unwrap();
1433 assert_eq!(ids.len(), 1);
1434 assert_eq!(
1435 ids[0],
1436 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1437 );
1438 }
1439
1440 #[test]
1443 fn sanitize_leaves_plain_terms_unquoted() {
1444 let result = sanitize_tantivy_query("hello world");
1445 assert_eq!(result, "hello world");
1446 }
1447
1448 #[test]
1449 fn sanitize_drops_punct_only_terms() {
1450 let result = sanitize_tantivy_query("*");
1451 assert_eq!(result, "");
1452 }
1454
1455 #[test]
1456 fn sanitize_escapes_boolean_operators() {
1457 let result = sanitize_tantivy_query("NOT secret");
1458 assert_eq!(result, "\"NOT\" secret");
1459 }
1460
1461 #[test]
1462 fn sanitize_escapes_field_syntax() {
1463 let result = sanitize_tantivy_query("content:secret");
1464 assert_eq!(result, "\"content:secret\"");
1465 }
1466
1467 #[test]
1468 fn sanitize_escapes_quotes() {
1469 let result = sanitize_tantivy_query("test\"injection");
1470 assert!(
1471 result.contains("\\\""),
1472 "embedded quotes should be escaped: {result}"
1473 );
1474 }
1475
1476 #[test]
1477 fn sanitize_escapes_trailing_backslash_token() {
1478 let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1481 assert_eq!(
1482 result, "\"C:\\\\Users\\\\temp\\\\\"",
1483 "backslashes must be doubled inside quoted terms"
1484 );
1485 }
1486
1487 #[test]
1488 fn lenient_fallback_never_fails_on_malformed_input() {
1489 let (_tmp, engine) = open_engine();
1490 let parser =
1491 QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1492 let searcher = engine.reader.searcher();
1493 let collector = TopDocs::with_limit(1).order_by_score();
1494 for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1495 let parsed = parse_query_with_fallback(&parser, malformed);
1496 searcher
1497 .search(&parsed, &collector)
1498 .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1499 }
1500 }
1501
1502 #[test]
1503 fn sanitize_empty_returns_empty() {
1504 assert_eq!(sanitize_tantivy_query(""), "");
1505 assert_eq!(sanitize_tantivy_query(" "), "");
1506 }
1507
1508 #[test]
1509 fn sanitize_preserves_alphanumeric() {
1510 let result = sanitize_tantivy_query("rust programming 2024");
1511 assert_eq!(result, "rust programming 2024");
1512 }
1513
1514 #[test]
1515 fn sanitize_preserves_hyphenated_compounds() {
1516 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1517 assert_eq!(result, "antigravity antigravity-project-test");
1518 }
1519
1520 #[test]
1523 fn strip_stopwords_removes_common_words() {
1524 assert_eq!(
1525 strip_stopwords("smoke test from wmClient"),
1526 "smoke test wmClient"
1527 );
1528 assert_eq!(strip_stopwords("the from and or"), "");
1529 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1530 assert_eq!(strip_stopwords(""), "");
1531 }
1532
1533 #[test]
1534 fn strip_stopwords_is_case_insensitive() {
1535 assert_eq!(strip_stopwords("FROM The And"), "");
1536 }
1537
1538 #[test]
1541 fn sanitize_content_skips_null_bytes() {
1542 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1543 assert!(sanitize_content_for_index(content).is_none());
1544 }
1545
1546 #[test]
1547 fn sanitize_content_skips_low_printable_ratio() {
1548 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1550 assert!(sanitize_content_for_index(content).is_none());
1551 }
1552
1553 #[test]
1554 fn sanitize_content_accepts_code_and_formatting_heavy_text() {
1555 let hex_list = "Casper\n#ACBFCD\n\nPickled Bluewood\n#324558\n\nComet\n#545B70\n";
1561 assert!(sanitize_content_for_index(hex_list).is_some());
1562 let html = "Sure, here's how:\n```html\n<!DOCTYPE html>\n<html>\n <body>\n \n \n </body>\n</html>\n```";
1563 assert!(sanitize_content_for_index(html).is_some());
1564 let binary = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1566 assert!(sanitize_content_for_index(binary).is_none());
1567 }
1568
1569 #[test]
1570 fn printable_ratio_treats_line_breaks_as_printable() {
1571 assert!((printable_ratio("a\nb\tc\rd") - 1.0).abs() < f32::EPSILON);
1572 assert!(printable_ratio("\u{01}\u{02}\u{03}\u{04}\u{05}hello") < 0.9);
1573 assert!((printable_ratio("") - 1.0).abs() < f32::EPSILON);
1574 }
1575
1576 #[test]
1577 fn sanitize_content_scrubs_and_caps() {
1578 let content = "clean text\u{01}with one control char";
1580 let cleaned = sanitize_content_for_index(content).unwrap();
1581 assert!(!cleaned.contains('\u{01}'));
1582 assert!(cleaned.starts_with("clean text with one control char"));
1583
1584 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1585 let capped = sanitize_content_for_index(&long).unwrap();
1586 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1587 }
1588
1589 #[test]
1590 fn sanitize_content_skips_empty() {
1591 assert!(sanitize_content_for_index("").is_none());
1592 assert!(sanitize_content_for_index(" ").is_none());
1593 }
1594
1595 #[test]
1596 fn scrub_text_replaces_control_chars() {
1597 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1598 assert_eq!(result, "a b\nc\td e");
1599 }
1600
1601 #[test]
1602 fn add_document_skips_binary_content() {
1603 let (_tmp, engine) = open_engine();
1604 let mut writer = engine.writer().unwrap();
1605
1606 engine
1607 .add_document(
1608 &mut writer,
1609 "11111111-1111-1111-1111-111111111111",
1610 "codex",
1611 "\u{00}\u{01}\u{02}raw serialized bytes",
1612 &[],
1613 1700000000,
1614 )
1615 .unwrap();
1616 engine
1617 .add_document(
1618 &mut writer,
1619 "22222222-2222-2222-2222-222222222222",
1620 "codex",
1621 "clean searchable text",
1622 &[],
1623 1700000001,
1624 )
1625 .unwrap();
1626 engine.commit(&mut writer).unwrap();
1627
1628 let results = engine.search("serialized", 10).unwrap();
1630 assert!(results.is_empty(), "binary content must not be indexed");
1631
1632 let results = engine.search("clean", 10).unwrap();
1633 assert_eq!(results.len(), 1);
1634 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1635 }
1636
1637 fn index_alpha_pair(engine: &SearchEngine) {
1640 let mut writer = engine.writer().unwrap();
1641 engine
1644 .add_document(
1645 &mut writer,
1646 "11111111-1111-1111-1111-111111111111",
1647 "codex",
1648 "alpha",
1649 &[],
1650 1700000000,
1651 )
1652 .unwrap();
1653 let filler = format!("alpha {}", "zzz ".repeat(400));
1654 engine
1655 .add_document(
1656 &mut writer,
1657 "22222222-2222-2222-2222-222222222222",
1658 "codex",
1659 &filler,
1660 &[],
1661 1700000001,
1662 )
1663 .unwrap();
1664 engine.commit(&mut writer).unwrap();
1665 }
1666
1667 #[test]
1668 fn strip_stopwords_ignores_trailing_punctuation() {
1669 assert_eq!(
1670 strip_stopwords("how many capabilities are there?"),
1671 "many capabilities"
1672 );
1673 }
1674
1675 #[test]
1680 fn question_with_absent_token_still_matches() {
1681 let (_tmp, engine) = open_engine();
1682 let mut writer = engine.writer().unwrap();
1683 engine
1684 .add_document(
1685 &mut writer,
1686 "33333333-3333-3333-3333-333333333333",
1687 "codex",
1688 "unique zebra content",
1689 &[],
1690 1700000002,
1691 )
1692 .unwrap();
1693 engine.commit(&mut writer).unwrap();
1694
1695 let results = engine.search("how many zebra stripes exist?", 5).unwrap();
1698 assert_eq!(
1699 results.len(),
1700 1,
1701 "the single valid hit must survive the coverage floor"
1702 );
1703 assert_eq!(results[0].memory_id, "33333333-3333-3333-3333-333333333333");
1704 }
1705
1706 #[test]
1707 fn search_absolute_min_score_filters_weak_matches() {
1708 let (_tmp, engine) = open_engine();
1709 index_alpha_pair(&engine);
1710
1711 let base = engine.search("alpha", 10).unwrap();
1712 assert_eq!(base.len(), 2);
1713 let (hi, lo) = if base[0].score >= base[1].score {
1714 (base[0].score, base[1].score)
1715 } else {
1716 (base[1].score, base[0].score)
1717 };
1718 assert!(
1719 hi > lo,
1720 "short doc should outscore long doc (hi={hi}, lo={lo})"
1721 );
1722 let mid = f32::midpoint(hi, lo);
1723
1724 let opts = SearchOptions {
1725 limit: 10,
1726 min_score: Some(mid),
1727 ..SearchOptions::default()
1728 };
1729 let filtered = engine.search_opt("alpha", &opts).unwrap();
1730 assert_eq!(filtered.len(), 1);
1731 assert!((filtered[0].score - hi).abs() < 1e-3);
1732 }
1733
1734 #[test]
1735 fn search_relative_floor_filters_weak_matches() {
1736 let (_tmp, engine) = open_engine();
1737 index_alpha_pair(&engine);
1738
1739 let opts = SearchOptions {
1740 limit: 10,
1741 relative_floor: Some(0.5),
1742 ..SearchOptions::default()
1743 };
1744 let filtered = engine.search_opt("alpha", &opts).unwrap();
1745 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1746 assert_eq!(
1747 filtered[0].memory_id,
1748 "11111111-1111-1111-1111-111111111111"
1749 );
1750 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1751 }
1752
1753 #[test]
1754 fn search_all_results_normalized() {
1755 let (_tmp, engine) = open_engine();
1756 index_alpha_pair(&engine);
1757
1758 let results = engine.search("alpha", 10).unwrap();
1759 assert_eq!(results.len(), 2);
1760 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1761 for r in &results[1..] {
1762 assert!(r.normalized_score <= 1.0);
1763 assert!(r.normalized_score > 0.0);
1764 }
1765 }
1766
1767 #[test]
1768 fn search_stemming_matches_morphological_variants() {
1769 let (_tmp, engine) = open_engine();
1772 let mut writer = engine.writer().unwrap();
1773
1774 engine
1775 .add_document(
1776 &mut writer,
1777 "11111111-1111-1111-1111-111111111111",
1778 "codex",
1779 "I graduated with a degree in Business Administration",
1780 &[],
1781 1700000000,
1782 )
1783 .unwrap();
1784 engine.commit(&mut writer).unwrap();
1785
1786 let results = engine.search("graduate", 10).unwrap();
1788 assert_eq!(results.len(), 1);
1789 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1790
1791 let results = engine.search("degrees", 10).unwrap();
1793 assert_eq!(results.len(), 1);
1794 }
1795
1796 #[test]
1797 fn search_incident_query_returns_only_relevant() {
1798 let (_tmp, engine) = open_engine();
1801 let mut writer = engine.writer().unwrap();
1802
1803 let smoke_id = "11111111-1111-1111-1111-111111111111";
1804 engine
1805 .add_document(
1806 &mut writer,
1807 smoke_id,
1808 "codex",
1809 "smoke test from wmClient: verify recall works",
1810 &[],
1811 1700000000,
1812 )
1813 .unwrap();
1814 let unrelated = [
1815 "NES Evolution and Impact: a history of the console wars",
1816 "Insights on The Gateless Gate: koans and zen practice",
1817 "What the tweet is really saying: a thread analysis",
1818 ];
1819 for (i, content) in (1i64..).zip(unrelated.iter()) {
1820 engine
1821 .add_document(
1822 &mut writer,
1823 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1824 "codex",
1825 content,
1826 &[],
1827 1700000000 + i,
1828 )
1829 .unwrap();
1830 }
1831 engine.commit(&mut writer).unwrap();
1832
1833 let results = engine.search("smoke test from wmClient", 20).unwrap();
1834 assert_eq!(
1835 results.len(),
1836 1,
1837 "only the smoke memory should match: {results:?}"
1838 );
1839 assert_eq!(results[0].memory_id, smoke_id);
1840 assert!(results[0].content.contains("smoke test"));
1841 }
1842
1843 #[test]
1844 fn search_project_compound_query() {
1845 let (_tmp, engine) = open_engine();
1847 let mut writer = engine.writer().unwrap();
1848
1849 engine
1850 .add_document(
1851 &mut writer,
1852 "11111111-1111-1111-1111-111111111111",
1853 "codex",
1854 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1855 &["project_antigravity-project-test".into()],
1856 1700000000,
1857 )
1858 .unwrap();
1859 engine.commit(&mut writer).unwrap();
1860
1861 let results = engine
1862 .search("antigravity antigravity-project-test", 10)
1863 .unwrap();
1864 assert!(
1865 !results.is_empty(),
1866 "project compound query must match the antigravity memory"
1867 );
1868 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1869 }
1870
1871 #[test]
1872 fn or_default_filters_partial_matches_via_coverage() {
1873 let (_tmp, engine) = open_engine();
1874 let mut writer = engine.writer().unwrap();
1875
1876 engine
1877 .add_document(
1878 &mut writer,
1879 "11111111-1111-1111-1111-111111111111",
1880 "codex",
1881 "alpha beta gamma delta",
1882 &[],
1883 1700000000,
1884 )
1885 .unwrap();
1886 engine
1887 .add_document(
1888 &mut writer,
1889 "22222222-2222-2222-2222-222222222222",
1890 "codex",
1891 "alpha only here",
1892 &[],
1893 1700000001,
1894 )
1895 .unwrap();
1896 engine.commit(&mut writer).unwrap();
1897
1898 let results = engine.search("alpha beta gamma", 10).unwrap();
1901 assert_eq!(results.len(), 1);
1902 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1903
1904 let results = engine.search("alpha beta", 10).unwrap();
1906 assert_eq!(results.len(), 2);
1907 }
1908
1909 #[test]
1910 fn coverage_is_case_insensitive() {
1911 let (_tmp, engine) = open_engine();
1912 let mut writer = engine.writer().unwrap();
1913 engine
1914 .add_document(
1915 &mut writer,
1916 "11111111-1111-1111-1111-111111111111",
1917 "codex",
1918 "Smoke Test for wmClient integration",
1919 &[],
1920 1700000000,
1921 )
1922 .unwrap();
1923 engine
1924 .add_document(
1925 &mut writer,
1926 "22222222-2222-2222-2222-222222222222",
1927 "codex",
1928 "test only here",
1929 &[],
1930 1700000001,
1931 )
1932 .unwrap();
1933 engine.commit(&mut writer).unwrap();
1934
1935 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1940 assert_eq!(results.len(), 1);
1941 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1942 }
1943
1944 #[test]
1945 fn coverage_matches_stemmed_variants() {
1946 let (_tmp, engine) = open_engine();
1949 let mut writer = engine.writer().unwrap();
1950 engine
1951 .add_document(
1952 &mut writer,
1953 "11111111-1111-1111-1111-111111111111",
1954 "codex",
1955 "I graduated with a degree in Business Administration",
1956 &[],
1957 1700000000,
1958 )
1959 .unwrap();
1960 engine
1961 .add_document(
1962 &mut writer,
1963 "22222222-2222-2222-2222-222222222222",
1964 "codex",
1965 "degree only here",
1966 &[],
1967 1700000001,
1968 )
1969 .unwrap();
1970 engine.commit(&mut writer).unwrap();
1971
1972 let results = engine.search("graduate degree", 10).unwrap();
1978 assert_eq!(results.len(), 2);
1979 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1980 }
1981
1982 #[test]
1983 fn coverage_normalizes_possessives_and_punctuation() {
1984 let query = "buy sister's birthday gift";
1985 assert_eq!(
1986 query_stem_tokens(query),
1987 ["buy", "sister", "birthday", "gift"]
1988 );
1989 assert_eq!(
1990 count_token_hits("I bought a dress for my sister birthday", query),
1991 2
1992 );
1993 }
1994
1995 #[test]
1996 fn search_stopword_only_query_returns_nothing() {
1997 let (_tmp, engine) = open_engine();
1998 let mut writer = engine.writer().unwrap();
1999 engine
2000 .add_document(
2001 &mut writer,
2002 "11111111-1111-1111-1111-111111111111",
2003 "codex",
2004 "some ordinary text",
2005 &[],
2006 1700000000,
2007 )
2008 .unwrap();
2009 engine.commit(&mut writer).unwrap();
2010
2011 let results = engine.search("the from and or", 10).unwrap();
2012 assert!(results.is_empty());
2013 }
2014
2015 #[test]
2016 fn wildcard_query_doesnt_match_all() {
2017 let (_tmp, engine) = open_engine();
2018 let mut writer = engine.writer().unwrap();
2019
2020 engine
2021 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
2022 .unwrap();
2023 engine
2024 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
2025 .unwrap();
2026 engine.commit(&mut writer).unwrap();
2027
2028 let results = engine.search("*", 10).unwrap();
2030 assert!(
2033 results.is_empty(),
2034 "wildcard query should not match all documents after sanitization"
2035 );
2036 }
2037
2038 #[test]
2039 fn field_syntax_query_doesnt_access_other_fields() {
2040 let (_tmp, engine) = open_engine();
2041 let mut writer = engine.writer().unwrap();
2042
2043 engine
2045 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
2046 .unwrap();
2047 engine.commit(&mut writer).unwrap();
2048
2049 let results = engine.search("galaxy:secret", 10).unwrap();
2051 assert!(
2054 results.is_empty(),
2055 "field syntax injection should not access non-searchable fields"
2056 );
2057 }
2058
2059 #[test]
2060 fn boolean_operator_doesnt_bypass_search() {
2061 let (_tmp, engine) = open_engine();
2062 let mut writer = engine.writer().unwrap();
2063
2064 engine
2065 .add_document(
2066 &mut writer,
2067 "uuid-1",
2068 "codex",
2069 "important secret data",
2070 &[],
2071 1000,
2072 )
2073 .unwrap();
2074 engine.commit(&mut writer).unwrap();
2075
2076 let results = engine.search("AND secret", 10).unwrap();
2080 assert_eq!(results.len(), 1);
2081
2082 let results = engine.search("AND OR NOT", 10).unwrap();
2085 assert!(
2086 results.is_empty(),
2087 "operator-only query must not bypass search"
2088 );
2089 }
2090}