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]
1048pub fn token_coverage(content: &str, query: &str) -> f64 {
1049 let stripped = strip_stopwords(query);
1050 let tokens = query_stem_tokens(&stripped);
1051 if tokens.is_empty() {
1052 return 0.0;
1053 }
1054 count_token_hits(content, &stripped) as f64 / tokens.len() as f64
1055}
1056
1057#[must_use]
1071pub fn printable_ratio(content: &str) -> f32 {
1072 let total = content.chars().count();
1073 if total == 0 {
1074 return 1.0;
1075 }
1076 let printable = content
1077 .chars()
1078 .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
1079 .count();
1080 printable as f32 / total as f32
1081}
1082
1083#[must_use]
1094pub fn sanitize_content_for_index(content: &str) -> Option<String> {
1095 if content.trim().is_empty() {
1096 return None;
1097 }
1098 if content.as_bytes().contains(&0) {
1099 return None;
1100 }
1101 if printable_ratio(content) < MIN_PRINTABLE_RATIO {
1102 return None;
1103 }
1104
1105 let cleaned = scrub_text(content);
1106 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1107 if capped.trim().is_empty() {
1108 None
1109 } else {
1110 Some(capped)
1111 }
1112}
1113
1114#[must_use]
1118pub fn scrub_text(content: &str) -> String {
1119 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1120 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1121 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1122 out.push(' ');
1123 } else {
1124 out.push(c);
1125 }
1126 }
1127 out
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133 use tempfile::tempdir;
1134
1135 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1136 let tmp = tempdir().unwrap();
1137 let engine = SearchEngine::open(tmp.path()).unwrap();
1138 (tmp, engine)
1139 }
1140
1141 #[test]
1145 fn search_rejects_zero_limit_without_panicking() {
1146 let (_tmp, engine) = open_engine();
1147 let err = engine.search("anything", 0).unwrap_err();
1148 assert!(err.to_string().contains("limit"), "{err}");
1149 let err = engine
1150 .search_in_galaxy("anything", Some(Galaxy::Codex), 0)
1151 .unwrap_err();
1152 assert!(err.to_string().contains("limit"), "{err}");
1153 let opts = SearchOptions {
1154 limit: 0,
1155 ..SearchOptions::default()
1156 };
1157 assert!(engine.search_opt("anything", &opts).is_err());
1158 }
1159
1160 fn write_incompatible_index(dir: &Path) {
1163 std::fs::create_dir_all(dir).unwrap();
1164 let mut builder = Schema::builder();
1165 builder.add_text_field("legacy", STRING | STORED);
1166 let schema = builder.build();
1167 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1168 Index::open_or_create(directory, schema).unwrap();
1169 }
1170
1171 #[test]
1172 fn open_migrates_incompatible_schema() {
1173 let tmp = tempdir().unwrap();
1174 let dir = tmp.path().join("tantivy");
1175 write_incompatible_index(&dir);
1176
1177 let engine = SearchEngine::open(&dir).unwrap();
1178 assert!(
1179 engine.schema_migrated(),
1180 "incompatible schema must trigger migration"
1181 );
1182
1183 let backups: Vec<_> = std::fs::read_dir(tmp.path())
1185 .unwrap()
1186 .filter_map(std::result::Result::ok)
1187 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1188 .collect();
1189 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1190
1191 let mut writer = engine.writer().unwrap();
1193 engine
1194 .add_document(
1195 &mut writer,
1196 "33333333-3333-3333-3333-333333333333",
1197 "codex",
1198 "fresh index after migration",
1199 &[],
1200 1700000000,
1201 )
1202 .unwrap();
1203 engine.commit(&mut writer).unwrap();
1204 let results = engine.search("fresh index", 10).unwrap();
1205 assert_eq!(results.len(), 1);
1206 }
1207
1208 #[test]
1209 fn writer_lock_error_names_path_and_hint() {
1210 let err = format_writer_lock_error(
1214 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1215 Path::new("/store/x/tantivy"),
1216 );
1217 assert!(
1218 err.contains("/store/x/tantivy"),
1219 "must name the index path: {err}"
1220 );
1221 assert!(
1222 err.contains("pgrep -af wm"),
1223 "must include the diagnostic hint: {err}"
1224 );
1225 assert!(
1226 err.contains("--readonly"),
1227 "must offer the readonly alternative: {err}"
1228 );
1229
1230 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1232 assert!(other.starts_with("Tantivy writer: disk full"));
1233 assert!(!other.contains("pgrep"));
1234 }
1235
1236 #[test]
1237 fn open_readonly_rejects_incompatible_schema() {
1238 let tmp = tempdir().unwrap();
1239 let dir = tmp.path().join("tantivy");
1240 write_incompatible_index(&dir);
1241
1242 let err = match SearchEngine::open_readonly(&dir) {
1243 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1244 Err(e) => e,
1245 };
1246 assert!(
1247 format!("{err}").contains("wm reindex"),
1248 "read-only mismatch must point at wm reindex, got: {err}"
1249 );
1250
1251 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1253 .unwrap()
1254 .filter_map(std::result::Result::ok)
1255 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1256 .collect();
1257 assert!(siblings.is_empty(), "read-only open must not migrate");
1258 }
1259
1260 #[test]
1261 fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1262 let tmp = tempdir().unwrap();
1263 let dir = tmp.path().join("tantivy");
1264 std::fs::create_dir_all(&dir).unwrap();
1265
1266 let err = match SearchEngine::open_readonly(&dir) {
1267 Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1268 Err(err) => err,
1269 };
1270 assert!(format!("{err}").contains("readonly open-existing"));
1271 assert!(
1272 std::fs::read_dir(&dir).unwrap().next().is_none(),
1273 "readonly open must not materialize Tantivy metadata or segments"
1274 );
1275 }
1276
1277 #[test]
1278 fn reopen_matching_schema_not_migrated() {
1279 let tmp = tempdir().unwrap();
1280 let dir = tmp.path().join("tantivy");
1281 std::fs::create_dir_all(&dir).unwrap();
1282
1283 let first = SearchEngine::open(&dir).unwrap();
1284 assert!(!first.schema_migrated());
1285 drop(first); let second = SearchEngine::open(&dir).unwrap();
1288 assert!(
1289 !second.schema_migrated(),
1290 "matching schema must not migrate"
1291 );
1292 drop(second);
1293
1294 let third = SearchEngine::open_readonly(&dir).unwrap();
1295 assert!(!third.schema_migrated());
1296 }
1297
1298 #[test]
1299 fn index_and_search_basic() {
1300 let (_tmp, engine) = open_engine();
1301 let mut writer = engine.writer().unwrap();
1302
1303 engine
1304 .add_document(
1305 &mut writer,
1306 "11111111-1111-1111-1111-111111111111",
1307 "codex",
1308 "The Rust programming language is fast and safe",
1309 &["rust".into(), "programming".into()],
1310 1700000000,
1311 )
1312 .unwrap();
1313 engine
1314 .add_document(
1315 &mut writer,
1316 "22222222-2222-2222-2222-222222222222",
1317 "codex",
1318 "Python is great for data science",
1319 &["python".into(), "data".into()],
1320 1700000001,
1321 )
1322 .unwrap();
1323 engine.commit(&mut writer).unwrap();
1324
1325 let results = engine.search("rust", 10).unwrap();
1326 assert!(!results.is_empty());
1327 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1328 }
1329
1330 #[test]
1331 fn search_by_tag() {
1332 let (_tmp, engine) = open_engine();
1333 let mut writer = engine.writer().unwrap();
1334
1335 engine
1336 .add_document(
1337 &mut writer,
1338 "11111111-1111-1111-1111-111111111111",
1339 "codex",
1340 "memory about systems",
1341 &["rust".into()],
1342 1700000000,
1343 )
1344 .unwrap();
1345 engine
1346 .add_document(
1347 &mut writer,
1348 "22222222-2222-2222-2222-222222222222",
1349 "codex",
1350 "memory about cooking",
1351 &["food".into()],
1352 1700000001,
1353 )
1354 .unwrap();
1355 engine.commit(&mut writer).unwrap();
1356
1357 let results = engine.search("rust", 10).unwrap();
1358 assert_eq!(results.len(), 1);
1359 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1360 }
1361
1362 #[test]
1363 fn search_filtered_by_galaxy() {
1364 let (_tmp, engine) = open_engine();
1365 let mut writer = engine.writer().unwrap();
1366
1367 engine
1368 .add_document(
1369 &mut writer,
1370 "11111111-1111-1111-1111-111111111111",
1371 "codex",
1372 "important knowledge",
1373 &[],
1374 1700000000,
1375 )
1376 .unwrap();
1377 engine
1378 .add_document(
1379 &mut writer,
1380 "22222222-2222-2222-2222-222222222222",
1381 "research",
1382 "important findings",
1383 &[],
1384 1700000001,
1385 )
1386 .unwrap();
1387 engine.commit(&mut writer).unwrap();
1388
1389 let results = engine
1390 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1391 .unwrap();
1392 assert_eq!(results.len(), 1);
1393 assert_eq!(results[0].galaxy, "codex");
1394 }
1395
1396 #[test]
1397 fn delete_document_from_index() {
1398 let (_tmp, engine) = open_engine();
1399 let mut writer = engine.writer().unwrap();
1400
1401 engine
1402 .add_document(
1403 &mut writer,
1404 "11111111-1111-1111-1111-111111111111",
1405 "codex",
1406 "deletable content",
1407 &[],
1408 1700000000,
1409 )
1410 .unwrap();
1411 engine.commit(&mut writer).unwrap();
1412
1413 let results = engine.search("deletable", 10).unwrap();
1414 assert_eq!(results.len(), 1);
1415
1416 engine
1417 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1418 .unwrap();
1419 engine.commit(&mut writer).unwrap();
1420
1421 let results = engine.search("deletable", 10).unwrap();
1422 assert_eq!(results.len(), 0);
1423 }
1424
1425 #[test]
1426 fn search_empty_index() {
1427 let (_tmp, engine) = open_engine();
1428 let results = engine.search("anything", 10).unwrap();
1429 assert!(results.is_empty());
1430 }
1431
1432 #[test]
1433 fn search_ids_returns_uuids() {
1434 let (_tmp, engine) = open_engine();
1435 let mut writer = engine.writer().unwrap();
1436
1437 engine
1438 .add_document(
1439 &mut writer,
1440 "11111111-1111-1111-1111-111111111111",
1441 "codex",
1442 "unique content about rust",
1443 &[],
1444 1700000000,
1445 )
1446 .unwrap();
1447 engine.commit(&mut writer).unwrap();
1448
1449 let ids = engine.search_ids("rust", 10).unwrap();
1450 assert_eq!(ids.len(), 1);
1451 assert_eq!(
1452 ids[0],
1453 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1454 );
1455 }
1456
1457 #[test]
1460 fn sanitize_leaves_plain_terms_unquoted() {
1461 let result = sanitize_tantivy_query("hello world");
1462 assert_eq!(result, "hello world");
1463 }
1464
1465 #[test]
1466 fn sanitize_drops_punct_only_terms() {
1467 let result = sanitize_tantivy_query("*");
1468 assert_eq!(result, "");
1469 }
1471
1472 #[test]
1473 fn sanitize_escapes_boolean_operators() {
1474 let result = sanitize_tantivy_query("NOT secret");
1475 assert_eq!(result, "\"NOT\" secret");
1476 }
1477
1478 #[test]
1479 fn sanitize_escapes_field_syntax() {
1480 let result = sanitize_tantivy_query("content:secret");
1481 assert_eq!(result, "\"content:secret\"");
1482 }
1483
1484 #[test]
1485 fn sanitize_escapes_quotes() {
1486 let result = sanitize_tantivy_query("test\"injection");
1487 assert!(
1488 result.contains("\\\""),
1489 "embedded quotes should be escaped: {result}"
1490 );
1491 }
1492
1493 #[test]
1494 fn sanitize_escapes_trailing_backslash_token() {
1495 let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1498 assert_eq!(
1499 result, "\"C:\\\\Users\\\\temp\\\\\"",
1500 "backslashes must be doubled inside quoted terms"
1501 );
1502 }
1503
1504 #[test]
1505 fn lenient_fallback_never_fails_on_malformed_input() {
1506 let (_tmp, engine) = open_engine();
1507 let parser =
1508 QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1509 let searcher = engine.reader.searcher();
1510 let collector = TopDocs::with_limit(1).order_by_score();
1511 for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1512 let parsed = parse_query_with_fallback(&parser, malformed);
1513 searcher
1514 .search(&parsed, &collector)
1515 .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1516 }
1517 }
1518
1519 #[test]
1520 fn sanitize_empty_returns_empty() {
1521 assert_eq!(sanitize_tantivy_query(""), "");
1522 assert_eq!(sanitize_tantivy_query(" "), "");
1523 }
1524
1525 #[test]
1526 fn sanitize_preserves_alphanumeric() {
1527 let result = sanitize_tantivy_query("rust programming 2024");
1528 assert_eq!(result, "rust programming 2024");
1529 }
1530
1531 #[test]
1532 fn sanitize_preserves_hyphenated_compounds() {
1533 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1534 assert_eq!(result, "antigravity antigravity-project-test");
1535 }
1536
1537 #[test]
1540 fn strip_stopwords_removes_common_words() {
1541 assert_eq!(
1542 strip_stopwords("smoke test from wmClient"),
1543 "smoke test wmClient"
1544 );
1545 assert_eq!(strip_stopwords("the from and or"), "");
1546 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1547 assert_eq!(strip_stopwords(""), "");
1548 }
1549
1550 #[test]
1551 fn strip_stopwords_is_case_insensitive() {
1552 assert_eq!(strip_stopwords("FROM The And"), "");
1553 }
1554
1555 #[test]
1558 fn sanitize_content_skips_null_bytes() {
1559 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1560 assert!(sanitize_content_for_index(content).is_none());
1561 }
1562
1563 #[test]
1564 fn sanitize_content_skips_low_printable_ratio() {
1565 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1567 assert!(sanitize_content_for_index(content).is_none());
1568 }
1569
1570 #[test]
1571 fn sanitize_content_accepts_code_and_formatting_heavy_text() {
1572 let hex_list = "Casper\n#ACBFCD\n\nPickled Bluewood\n#324558\n\nComet\n#545B70\n";
1578 assert!(sanitize_content_for_index(hex_list).is_some());
1579 let html = "Sure, here's how:\n```html\n<!DOCTYPE html>\n<html>\n <body>\n \n \n </body>\n</html>\n```";
1580 assert!(sanitize_content_for_index(html).is_some());
1581 let binary = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1583 assert!(sanitize_content_for_index(binary).is_none());
1584 }
1585
1586 #[test]
1587 fn printable_ratio_treats_line_breaks_as_printable() {
1588 assert!((printable_ratio("a\nb\tc\rd") - 1.0).abs() < f32::EPSILON);
1589 assert!(printable_ratio("\u{01}\u{02}\u{03}\u{04}\u{05}hello") < 0.9);
1590 assert!((printable_ratio("") - 1.0).abs() < f32::EPSILON);
1591 }
1592
1593 #[test]
1594 fn sanitize_content_scrubs_and_caps() {
1595 let content = "clean text\u{01}with one control char";
1597 let cleaned = sanitize_content_for_index(content).unwrap();
1598 assert!(!cleaned.contains('\u{01}'));
1599 assert!(cleaned.starts_with("clean text with one control char"));
1600
1601 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1602 let capped = sanitize_content_for_index(&long).unwrap();
1603 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1604 }
1605
1606 #[test]
1607 fn sanitize_content_skips_empty() {
1608 assert!(sanitize_content_for_index("").is_none());
1609 assert!(sanitize_content_for_index(" ").is_none());
1610 }
1611
1612 #[test]
1613 fn scrub_text_replaces_control_chars() {
1614 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1615 assert_eq!(result, "a b\nc\td e");
1616 }
1617
1618 #[test]
1619 fn add_document_skips_binary_content() {
1620 let (_tmp, engine) = open_engine();
1621 let mut writer = engine.writer().unwrap();
1622
1623 engine
1624 .add_document(
1625 &mut writer,
1626 "11111111-1111-1111-1111-111111111111",
1627 "codex",
1628 "\u{00}\u{01}\u{02}raw serialized bytes",
1629 &[],
1630 1700000000,
1631 )
1632 .unwrap();
1633 engine
1634 .add_document(
1635 &mut writer,
1636 "22222222-2222-2222-2222-222222222222",
1637 "codex",
1638 "clean searchable text",
1639 &[],
1640 1700000001,
1641 )
1642 .unwrap();
1643 engine.commit(&mut writer).unwrap();
1644
1645 let results = engine.search("serialized", 10).unwrap();
1647 assert!(results.is_empty(), "binary content must not be indexed");
1648
1649 let results = engine.search("clean", 10).unwrap();
1650 assert_eq!(results.len(), 1);
1651 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1652 }
1653
1654 fn index_alpha_pair(engine: &SearchEngine) {
1657 let mut writer = engine.writer().unwrap();
1658 engine
1661 .add_document(
1662 &mut writer,
1663 "11111111-1111-1111-1111-111111111111",
1664 "codex",
1665 "alpha",
1666 &[],
1667 1700000000,
1668 )
1669 .unwrap();
1670 let filler = format!("alpha {}", "zzz ".repeat(400));
1671 engine
1672 .add_document(
1673 &mut writer,
1674 "22222222-2222-2222-2222-222222222222",
1675 "codex",
1676 &filler,
1677 &[],
1678 1700000001,
1679 )
1680 .unwrap();
1681 engine.commit(&mut writer).unwrap();
1682 }
1683
1684 #[test]
1685 fn strip_stopwords_ignores_trailing_punctuation() {
1686 assert_eq!(
1687 strip_stopwords("how many capabilities are there?"),
1688 "many capabilities"
1689 );
1690 }
1691
1692 #[test]
1697 fn question_with_absent_token_still_matches() {
1698 let (_tmp, engine) = open_engine();
1699 let mut writer = engine.writer().unwrap();
1700 engine
1701 .add_document(
1702 &mut writer,
1703 "33333333-3333-3333-3333-333333333333",
1704 "codex",
1705 "unique zebra content",
1706 &[],
1707 1700000002,
1708 )
1709 .unwrap();
1710 engine.commit(&mut writer).unwrap();
1711
1712 let results = engine.search("how many zebra stripes exist?", 5).unwrap();
1715 assert_eq!(
1716 results.len(),
1717 1,
1718 "the single valid hit must survive the coverage floor"
1719 );
1720 assert_eq!(results[0].memory_id, "33333333-3333-3333-3333-333333333333");
1721 }
1722
1723 #[test]
1724 fn search_absolute_min_score_filters_weak_matches() {
1725 let (_tmp, engine) = open_engine();
1726 index_alpha_pair(&engine);
1727
1728 let base = engine.search("alpha", 10).unwrap();
1729 assert_eq!(base.len(), 2);
1730 let (hi, lo) = if base[0].score >= base[1].score {
1731 (base[0].score, base[1].score)
1732 } else {
1733 (base[1].score, base[0].score)
1734 };
1735 assert!(
1736 hi > lo,
1737 "short doc should outscore long doc (hi={hi}, lo={lo})"
1738 );
1739 let mid = f32::midpoint(hi, lo);
1740
1741 let opts = SearchOptions {
1742 limit: 10,
1743 min_score: Some(mid),
1744 ..SearchOptions::default()
1745 };
1746 let filtered = engine.search_opt("alpha", &opts).unwrap();
1747 assert_eq!(filtered.len(), 1);
1748 assert!((filtered[0].score - hi).abs() < 1e-3);
1749 }
1750
1751 #[test]
1752 fn search_relative_floor_filters_weak_matches() {
1753 let (_tmp, engine) = open_engine();
1754 index_alpha_pair(&engine);
1755
1756 let opts = SearchOptions {
1757 limit: 10,
1758 relative_floor: Some(0.5),
1759 ..SearchOptions::default()
1760 };
1761 let filtered = engine.search_opt("alpha", &opts).unwrap();
1762 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1763 assert_eq!(
1764 filtered[0].memory_id,
1765 "11111111-1111-1111-1111-111111111111"
1766 );
1767 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1768 }
1769
1770 #[test]
1771 fn search_all_results_normalized() {
1772 let (_tmp, engine) = open_engine();
1773 index_alpha_pair(&engine);
1774
1775 let results = engine.search("alpha", 10).unwrap();
1776 assert_eq!(results.len(), 2);
1777 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1778 for r in &results[1..] {
1779 assert!(r.normalized_score <= 1.0);
1780 assert!(r.normalized_score > 0.0);
1781 }
1782 }
1783
1784 #[test]
1785 fn search_stemming_matches_morphological_variants() {
1786 let (_tmp, engine) = open_engine();
1789 let mut writer = engine.writer().unwrap();
1790
1791 engine
1792 .add_document(
1793 &mut writer,
1794 "11111111-1111-1111-1111-111111111111",
1795 "codex",
1796 "I graduated with a degree in Business Administration",
1797 &[],
1798 1700000000,
1799 )
1800 .unwrap();
1801 engine.commit(&mut writer).unwrap();
1802
1803 let results = engine.search("graduate", 10).unwrap();
1805 assert_eq!(results.len(), 1);
1806 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1807
1808 let results = engine.search("degrees", 10).unwrap();
1810 assert_eq!(results.len(), 1);
1811 }
1812
1813 #[test]
1814 fn search_incident_query_returns_only_relevant() {
1815 let (_tmp, engine) = open_engine();
1818 let mut writer = engine.writer().unwrap();
1819
1820 let smoke_id = "11111111-1111-1111-1111-111111111111";
1821 engine
1822 .add_document(
1823 &mut writer,
1824 smoke_id,
1825 "codex",
1826 "smoke test from wmClient: verify recall works",
1827 &[],
1828 1700000000,
1829 )
1830 .unwrap();
1831 let unrelated = [
1832 "NES Evolution and Impact: a history of the console wars",
1833 "Insights on The Gateless Gate: koans and zen practice",
1834 "What the tweet is really saying: a thread analysis",
1835 ];
1836 for (i, content) in (1i64..).zip(unrelated.iter()) {
1837 engine
1838 .add_document(
1839 &mut writer,
1840 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1841 "codex",
1842 content,
1843 &[],
1844 1700000000 + i,
1845 )
1846 .unwrap();
1847 }
1848 engine.commit(&mut writer).unwrap();
1849
1850 let results = engine.search("smoke test from wmClient", 20).unwrap();
1851 assert_eq!(
1852 results.len(),
1853 1,
1854 "only the smoke memory should match: {results:?}"
1855 );
1856 assert_eq!(results[0].memory_id, smoke_id);
1857 assert!(results[0].content.contains("smoke test"));
1858 }
1859
1860 #[test]
1861 fn search_project_compound_query() {
1862 let (_tmp, engine) = open_engine();
1864 let mut writer = engine.writer().unwrap();
1865
1866 engine
1867 .add_document(
1868 &mut writer,
1869 "11111111-1111-1111-1111-111111111111",
1870 "codex",
1871 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1872 &["project_antigravity-project-test".into()],
1873 1700000000,
1874 )
1875 .unwrap();
1876 engine.commit(&mut writer).unwrap();
1877
1878 let results = engine
1879 .search("antigravity antigravity-project-test", 10)
1880 .unwrap();
1881 assert!(
1882 !results.is_empty(),
1883 "project compound query must match the antigravity memory"
1884 );
1885 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1886 }
1887
1888 #[test]
1889 fn or_default_filters_partial_matches_via_coverage() {
1890 let (_tmp, engine) = open_engine();
1891 let mut writer = engine.writer().unwrap();
1892
1893 engine
1894 .add_document(
1895 &mut writer,
1896 "11111111-1111-1111-1111-111111111111",
1897 "codex",
1898 "alpha beta gamma delta",
1899 &[],
1900 1700000000,
1901 )
1902 .unwrap();
1903 engine
1904 .add_document(
1905 &mut writer,
1906 "22222222-2222-2222-2222-222222222222",
1907 "codex",
1908 "alpha only here",
1909 &[],
1910 1700000001,
1911 )
1912 .unwrap();
1913 engine.commit(&mut writer).unwrap();
1914
1915 let results = engine.search("alpha beta gamma", 10).unwrap();
1918 assert_eq!(results.len(), 1);
1919 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1920
1921 let results = engine.search("alpha beta", 10).unwrap();
1923 assert_eq!(results.len(), 2);
1924 }
1925
1926 #[test]
1927 fn coverage_is_case_insensitive() {
1928 let (_tmp, engine) = open_engine();
1929 let mut writer = engine.writer().unwrap();
1930 engine
1931 .add_document(
1932 &mut writer,
1933 "11111111-1111-1111-1111-111111111111",
1934 "codex",
1935 "Smoke Test for wmClient integration",
1936 &[],
1937 1700000000,
1938 )
1939 .unwrap();
1940 engine
1941 .add_document(
1942 &mut writer,
1943 "22222222-2222-2222-2222-222222222222",
1944 "codex",
1945 "test only here",
1946 &[],
1947 1700000001,
1948 )
1949 .unwrap();
1950 engine.commit(&mut writer).unwrap();
1951
1952 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1957 assert_eq!(results.len(), 1);
1958 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1959 }
1960
1961 #[test]
1962 fn coverage_matches_stemmed_variants() {
1963 let (_tmp, engine) = open_engine();
1966 let mut writer = engine.writer().unwrap();
1967 engine
1968 .add_document(
1969 &mut writer,
1970 "11111111-1111-1111-1111-111111111111",
1971 "codex",
1972 "I graduated with a degree in Business Administration",
1973 &[],
1974 1700000000,
1975 )
1976 .unwrap();
1977 engine
1978 .add_document(
1979 &mut writer,
1980 "22222222-2222-2222-2222-222222222222",
1981 "codex",
1982 "degree only here",
1983 &[],
1984 1700000001,
1985 )
1986 .unwrap();
1987 engine.commit(&mut writer).unwrap();
1988
1989 let results = engine.search("graduate degree", 10).unwrap();
1995 assert_eq!(results.len(), 2);
1996 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1997 }
1998
1999 #[test]
2000 fn token_coverage_counts_stemmed_whole_words() {
2001 let content = "Carried the stories for classes in Business Administration";
2003 assert_eq!(
2004 token_coverage(content, "carry stories classes business"),
2005 1.0
2006 );
2007 assert_eq!(
2008 token_coverage(content, "carry stories classes certificate"),
2009 0.75
2010 );
2011 assert_eq!(token_coverage(content, "story"), 1.0);
2015 assert_eq!(token_coverage(content, "unrelated nonsense"), 0.0);
2016 assert_eq!(token_coverage(content, "the and of"), 0.0);
2018 assert_eq!(token_coverage(content, ""), 0.0);
2019 assert_eq!(token_coverage(content, "BUSINESS administration"), 1.0);
2021 }
2022
2023 #[test]
2024 fn coverage_normalizes_possessives_and_punctuation() {
2025 let query = "buy sister's birthday gift";
2026 assert_eq!(
2027 query_stem_tokens(query),
2028 ["buy", "sister", "birthday", "gift"]
2029 );
2030 assert_eq!(
2031 count_token_hits("I bought a dress for my sister birthday", query),
2032 2
2033 );
2034 }
2035
2036 #[test]
2037 fn search_stopword_only_query_returns_nothing() {
2038 let (_tmp, engine) = open_engine();
2039 let mut writer = engine.writer().unwrap();
2040 engine
2041 .add_document(
2042 &mut writer,
2043 "11111111-1111-1111-1111-111111111111",
2044 "codex",
2045 "some ordinary text",
2046 &[],
2047 1700000000,
2048 )
2049 .unwrap();
2050 engine.commit(&mut writer).unwrap();
2051
2052 let results = engine.search("the from and or", 10).unwrap();
2053 assert!(results.is_empty());
2054 }
2055
2056 #[test]
2057 fn wildcard_query_doesnt_match_all() {
2058 let (_tmp, engine) = open_engine();
2059 let mut writer = engine.writer().unwrap();
2060
2061 engine
2062 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
2063 .unwrap();
2064 engine
2065 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
2066 .unwrap();
2067 engine.commit(&mut writer).unwrap();
2068
2069 let results = engine.search("*", 10).unwrap();
2071 assert!(
2074 results.is_empty(),
2075 "wildcard query should not match all documents after sanitization"
2076 );
2077 }
2078
2079 #[test]
2080 fn field_syntax_query_doesnt_access_other_fields() {
2081 let (_tmp, engine) = open_engine();
2082 let mut writer = engine.writer().unwrap();
2083
2084 engine
2086 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
2087 .unwrap();
2088 engine.commit(&mut writer).unwrap();
2089
2090 let results = engine.search("galaxy:secret", 10).unwrap();
2092 assert!(
2095 results.is_empty(),
2096 "field syntax injection should not access non-searchable fields"
2097 );
2098 }
2099
2100 #[test]
2101 fn boolean_operator_doesnt_bypass_search() {
2102 let (_tmp, engine) = open_engine();
2103 let mut writer = engine.writer().unwrap();
2104
2105 engine
2106 .add_document(
2107 &mut writer,
2108 "uuid-1",
2109 "codex",
2110 "important secret data",
2111 &[],
2112 1000,
2113 )
2114 .unwrap();
2115 engine.commit(&mut writer).unwrap();
2116
2117 let results = engine.search("AND secret", 10).unwrap();
2121 assert_eq!(results.len(), 1);
2122
2123 let results = engine.search("AND OR NOT", 10).unwrap();
2126 assert!(
2127 results.is_empty(),
2128 "operator-only query must not bypass search"
2129 );
2130 }
2131}