1use crate::MemoryId;
16use serde::{Deserialize, Serialize};
17use wm_core::{CoreError, Galaxy, Result};
18
19use std::path::Path;
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicU64, Ordering};
22use tantivy::{
23 Index, IndexReader, IndexWriter, ReloadPolicy,
24 collector::TopDocs,
25 doc,
26 query::QueryParser,
27 schema::{
28 Field, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, TextOptions, Value,
29 },
30};
31
32pub const MAX_INDEX_CONTENT_LEN: usize = 8 * 1024;
34
35pub const MIN_PRINTABLE_RATIO: f32 = 0.9;
37
38pub const STOPWORDS: &[&str] = &[
43 "a",
44 "about",
45 "after",
46 "again",
47 "all",
48 "also",
49 "am",
50 "an",
51 "and",
52 "any",
53 "are",
54 "as",
55 "at",
56 "be",
57 "been",
58 "being",
59 "before",
60 "between",
61 "both",
62 "but",
63 "by",
64 "can",
65 "could",
66 "did",
67 "do",
68 "does",
69 "during",
70 "each",
71 "few",
72 "for",
73 "from",
74 "further",
75 "had",
76 "has",
77 "have",
78 "he",
79 "her",
80 "here",
81 "hers",
82 "herself",
83 "him",
84 "himself",
85 "his",
86 "how",
87 "i",
88 "if",
89 "in",
90 "into",
91 "is",
92 "it",
93 "its",
94 "itself",
95 "just",
96 "me",
97 "might",
98 "more",
99 "most",
100 "my",
101 "myself",
102 "no",
103 "nor",
104 "not",
105 "of",
106 "off",
107 "on",
108 "once",
109 "only",
110 "or",
111 "other",
112 "our",
113 "ours",
114 "ourselves",
115 "out",
116 "over",
117 "own",
118 "same",
119 "shall",
120 "she",
121 "should",
122 "so",
123 "some",
124 "such",
125 "than",
126 "that",
127 "the",
128 "their",
129 "theirs",
130 "them",
131 "themselves",
132 "then",
133 "there",
134 "these",
135 "they",
136 "this",
137 "those",
138 "through",
139 "to",
140 "too",
141 "under",
142 "until",
143 "up",
144 "us",
145 "very",
146 "was",
147 "we",
148 "were",
149 "what",
150 "when",
151 "where",
152 "which",
153 "while",
154 "who",
155 "whom",
156 "why",
157 "will",
158 "with",
159 "would",
160 "you",
161 "your",
162 "yours",
163 "yourself",
164 "yourselves",
165];
166
167#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
169pub struct SearchOptions {
170 pub limit: usize,
172 pub galaxy: Option<Galaxy>,
174 pub min_score: Option<f32>,
176 pub relative_floor: Option<f32>,
179 pub relaxed: bool,
183}
184
185impl Default for SearchOptions {
186 fn default() -> Self {
187 Self {
188 limit: 20,
189 galaxy: None,
190 min_score: None,
191 relative_floor: None,
192 relaxed: false,
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SearchResult {
200 pub memory_id: String,
202 pub galaxy: String,
204 pub score: f32,
206 pub normalized_score: f32,
208 pub content: String,
210}
211
212#[derive(Debug, Default)]
219pub struct IndexHealth {
220 pub successes: AtomicU64,
222 pub failures: AtomicU64,
224 last_error: Mutex<String>,
226}
227
228impl IndexHealth {
229 fn record_success(&self) {
230 self.successes.fetch_add(1, Ordering::Relaxed);
231 }
232
233 fn record_failure(&self, err: &str) {
234 self.failures.fetch_add(1, Ordering::Relaxed);
235 if let Ok(mut guard) = self.last_error.lock() {
236 *guard = err.to_string();
237 }
238 }
239
240 #[must_use]
242 pub fn snapshot(&self) -> serde_json::Value {
243 let successes = self.successes.load(Ordering::Relaxed);
244 let failures = self.failures.load(Ordering::Relaxed);
245 let last_error = self
246 .last_error
247 .lock()
248 .map(|g| g.clone())
249 .unwrap_or_default();
250 let degraded = failures > 0;
251 serde_json::json!({
252 "successes": successes,
253 "failures": failures,
254 "degraded": degraded,
255 "last_error": if last_error.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(last_error) },
256 })
257 }
258}
259
260fn format_writer_lock_error(err: &str, index_path: &Path) -> String {
264 let is_lock = err.contains("ock") && (err.contains("Busy") || err.contains("lock"));
265 if is_lock {
266 format!(
267 "Tantivy writer: {err} — the search index at {} is locked by another process. \
268 A running `wm serve` or `wm daemon` on this store holds it; find it with \
269 `pgrep -af wm` and stop it, or start this server with --readonly.",
270 index_path.display()
271 )
272 } else {
273 format!("Tantivy writer: {err}")
274 }
275}
276
277pub struct SearchEngine {
279 index: Index,
280 reader: IndexReader,
281 writer: Mutex<Option<IndexWriter>>,
282 field_id: Field,
283 field_galaxy: Field,
284 field_content: Field,
285 field_tags: Field,
286 field_timestamp: Field,
287 health: IndexHealth,
290 schema_migrated: bool,
296}
297
298impl SearchEngine {
299 fn build_schema() -> (Schema, Field, Field, Field, Field, Field) {
301 let mut schema_builder = Schema::builder();
302 let field_id = schema_builder.add_text_field("memory_id", STRING | STORED);
303 let field_galaxy = schema_builder.add_text_field("galaxy", STRING | STORED);
304 let stem_indexing = TextFieldIndexing::default()
307 .set_tokenizer("en_stem")
308 .set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions);
309 let stem_text = TextOptions::default()
310 .set_indexing_options(stem_indexing.clone())
311 .set_stored();
312 let stem_tags = TextOptions::default().set_indexing_options(stem_indexing);
313 let field_content = schema_builder.add_text_field("content", stem_text);
314 let field_tags = schema_builder.add_text_field("tags", stem_tags);
315 let field_timestamp = schema_builder.add_i64_field("timestamp", STORED);
316 let schema = schema_builder.build();
317 (
318 schema,
319 field_id,
320 field_galaxy,
321 field_content,
322 field_tags,
323 field_timestamp,
324 )
325 }
326
327 fn open_index(path: &Path, schema: &Schema, writable: bool) -> Result<(Index, bool)> {
338 let directory = tantivy::directory::MmapDirectory::open(path)
339 .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
340 match Index::open_or_create(directory, schema.clone()) {
341 Ok(index) => Ok((index, false)),
342 Err(tantivy::error::TantivyError::SchemaError(_)) => {
343 if !writable {
344 return Err(CoreError::Memory(format!(
345 "Tantivy index at {} was created with an incompatible schema by an \
346 older version. Run 'wm reindex' (or start 'wm serve' without \
347 --readonly) to migrate and rebuild it from the canonical store.",
348 path.display()
349 )));
350 }
351 let ts = std::time::SystemTime::now()
352 .duration_since(std::time::UNIX_EPOCH)
353 .map_or(0, |d| d.as_millis());
354 let file_name = path
355 .file_name()
356 .and_then(|n| n.to_str())
357 .unwrap_or("tantivy");
358 let backup = path.with_file_name(format!("{file_name}.schema-mismatch.{ts}"));
359 std::fs::rename(path, &backup).map_err(|e| {
360 CoreError::Memory(format!(
361 "Tantivy schema migration — rename old index to {}: {e}",
362 backup.display()
363 ))
364 })?;
365 std::fs::create_dir_all(path).map_err(|e| {
366 CoreError::Memory(format!("Tantivy schema migration — create index dir: {e}"))
367 })?;
368 tracing::warn!(
369 "Tantivy index schema mismatch — old index moved to {}; creating a fresh \
370 index (rebuild from LMDB will follow)",
371 backup.display()
372 );
373 let directory = tantivy::directory::MmapDirectory::open(path)
374 .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
375 let index = Index::open_or_create(directory, schema.clone())
376 .map_err(|e| CoreError::Memory(format!("Tantivy open_or_create: {e}")))?;
377 Ok((index, true))
378 }
379 Err(e) => Err(CoreError::Memory(format!("Tantivy open_or_create: {e}"))),
380 }
381 }
382
383 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
385 let path = path.as_ref();
386 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
387 Self::build_schema();
388
389 let (index, schema_migrated) = Self::open_index(path, &schema, true)?;
390
391 let reader = index
392 .reader_builder()
393 .reload_policy(ReloadPolicy::OnCommitWithDelay)
394 .try_into()
395 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
396
397 let writer = index
398 .writer(50_000_000)
399 .map_err(|e| CoreError::Memory(format_writer_lock_error(&e.to_string(), path)))?;
400
401 Ok(Self {
402 index,
403 reader,
404 writer: Mutex::new(Some(writer)),
405 field_id,
406 field_galaxy,
407 field_content,
408 field_tags,
409 field_timestamp,
410 health: IndexHealth::default(),
411 schema_migrated,
412 })
413 }
414
415 pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
420 let path = path.as_ref();
421 tracing::warn!(
424 "read-only search index opened at {} — it will not observe writes made \
425 after this point; restart the read-only server to pick up new memories",
426 path.display()
427 );
428 let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
429 Self::build_schema();
430 let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
431 let reader = index
432 .reader_builder()
433 .reload_policy(ReloadPolicy::OnCommitWithDelay)
434 .try_into()
435 .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
436 Ok(Self {
437 index,
438 reader,
439 writer: Mutex::new(None),
440 field_id,
441 field_galaxy,
442 field_content,
443 field_tags,
444 field_timestamp,
445 health: IndexHealth::default(),
446 schema_migrated,
447 })
448 }
449
450 #[must_use]
456 pub const fn schema_migrated(&self) -> bool {
457 self.schema_migrated
458 }
459
460 #[must_use]
463 pub const fn health(&self) -> &IndexHealth {
464 &self.health
465 }
466
467 pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
473 let searcher = self.reader.searcher();
474 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
475 let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
476 let count = searcher
477 .search(&query, &tantivy::collector::Count)
478 .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
479 Ok(count)
480 }
481
482 pub fn is_readonly(&self) -> bool {
484 self.writer.lock().map_or(true, |g| g.is_none())
485 }
486
487 pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
493 let guard = self
494 .writer
495 .lock()
496 .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
497 if guard.is_none() {
498 return Err(CoreError::Memory(
499 "Tantivy writer unavailable: index opened read-only".into(),
500 ));
501 }
502 Ok(guard)
503 }
504
505 pub fn add_document(
512 &self,
513 writer: &mut Option<IndexWriter>,
514 memory_id: &str,
515 galaxy: &str,
516 content: &str,
517 tags: &[String],
518 timestamp: i64,
519 ) -> Result<()> {
520 let writer = writer.as_mut().ok_or_else(|| {
521 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
522 })?;
523 let Some(clean_content) = sanitize_content_for_index(content) else {
524 tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
525 return Ok(());
526 };
527 let tags_str = tags.join(" ");
528 let doc = doc!(
529 self.field_id => memory_id,
530 self.field_galaxy => galaxy,
531 self.field_content => clean_content,
532 self.field_tags => tags_str,
533 self.field_timestamp => timestamp,
534 );
535 match writer.add_document(doc) {
536 Ok(_) => {
537 self.health.record_success();
538 Ok(())
539 }
540 Err(e) => {
541 let msg = format!("Tantivy add_document: {e}");
542 self.health.record_failure(&msg);
543 Err(CoreError::Memory(msg))
544 }
545 }
546 }
547
548 pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
550 let writer = writer.as_mut().ok_or_else(|| {
551 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
552 })?;
553 let term = tantivy::Term::from_field_text(self.field_id, memory_id);
554 writer.delete_term(term);
555 Ok(())
556 }
557
558 pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
563 let writer = writer.as_mut().ok_or_else(|| {
564 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
565 })?;
566 let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
567 writer.delete_term(term);
568 Ok(())
569 }
570
571 pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
573 let writer = writer.as_mut().ok_or_else(|| {
574 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
575 })?;
576 writer
577 .commit()
578 .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
579 self.reader
580 .reload()
581 .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
582 Ok(())
583 }
584
585 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
591 let opts = SearchOptions {
592 limit,
593 ..SearchOptions::default()
594 };
595 self.search_opt(query, &opts)
596 }
597
598 pub fn search_in_galaxy(
604 &self,
605 query: &str,
606 galaxy: Option<Galaxy>,
607 limit: usize,
608 ) -> Result<Vec<SearchResult>> {
609 let opts = SearchOptions {
610 limit,
611 galaxy,
612 ..SearchOptions::default()
613 };
614 self.search_opt(query, &opts)
615 }
616
617 pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
634 let stripped = strip_stopwords(query);
635 let sanitized = sanitize_tantivy_query(&stripped);
636 if sanitized.trim().is_empty() {
637 return Ok(Vec::new());
638 }
639
640 let searcher = self.reader.searcher();
641
642 let query_parser =
645 QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
646
647 let parsed = query_parser
648 .parse_query(&sanitized)
649 .map_err(|e| CoreError::Memory(format!("Tantivy parse_query: {e}")))?;
650
651 let collector = TopDocs::with_limit(opts.limit).order_by_score();
652
653 let top_docs = searcher
654 .search(&parsed, &collector)
655 .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
656
657 let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
658 let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
659 let relative_floor = opts
660 .relative_floor
661 .map_or(f32::MIN, |ratio| top_score * ratio);
662
663 let query_tokens = query_stem_tokens(&stripped);
667 let coverage_floor = if query_tokens.len() >= 3 { 2 } else { 1 };
668
669 let mut results = Vec::new();
670 for (score, doc_address) in top_docs {
671 if score < absolute_floor || score < relative_floor {
673 continue;
674 }
675
676 let doc: TantivyDocument = searcher
677 .doc(doc_address)
678 .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
679
680 let memory_id = doc
681 .get_first(self.field_id)
682 .and_then(|v| v.as_str())
683 .unwrap_or("")
684 .to_string();
685
686 let doc_galaxy = doc
687 .get_first(self.field_galaxy)
688 .and_then(|v| v.as_str())
689 .unwrap_or("")
690 .to_string();
691
692 if let Some(g) = opts.galaxy {
693 if doc_galaxy != g.db_name() {
694 continue;
695 }
696 }
697
698 let content = doc
699 .get_first(self.field_content)
700 .and_then(|v| v.as_str())
701 .unwrap_or("")
702 .to_string();
703
704 if coverage_floor > 1 {
705 let hits = count_token_hits(&content, &stripped);
706 if hits < coverage_floor {
707 continue;
708 }
709 }
710
711 let boosted_score = if query_tokens.is_empty() {
714 score
715 } else {
716 let hits = count_token_hits(&content, &stripped);
717 let ratio = hits as f32 / query_tokens.len() as f32;
718 score * 0.1f32.mul_add(ratio, 1.0)
719 };
720
721 results.push(SearchResult {
722 memory_id,
723 galaxy: doc_galaxy,
724 score: boosted_score,
725 normalized_score: 0.0, content: scrub_text(&content),
727 });
728 }
729
730 results.sort_by(|a, b| {
732 b.score
733 .partial_cmp(&a.score)
734 .unwrap_or(std::cmp::Ordering::Equal)
735 });
736
737 let top_boosted = results.first().map_or(0.0, |r| r.score);
739 for r in &mut results {
740 r.normalized_score = if top_boosted > 0.0 {
741 r.score / top_boosted
742 } else {
743 0.0
744 };
745 }
746
747 Ok(results)
748 }
749
750 pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
752 let results = self.search(query, limit)?;
753 Ok(results
754 .into_iter()
755 .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
756 .collect())
757 }
758}
759
760#[must_use]
777pub fn sanitize_tantivy_query(input: &str) -> String {
778 if input.trim().is_empty() {
780 return String::new();
781 }
782
783 input
784 .split_whitespace()
785 .filter(|term| term.chars().any(char::is_alphanumeric))
786 .map(|term| {
787 if term_needs_quoting(term) {
788 let escaped = term.replace('"', "\\\"");
790 format!("\"{escaped}\"")
791 } else {
792 term.to_string()
793 }
794 })
795 .collect::<Vec<_>>()
796 .join(" ")
797}
798
799#[must_use]
801fn term_needs_quoting(term: &str) -> bool {
802 if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
803 return true;
804 }
805 if term == "AND" || term == "OR" || term == "NOT" {
806 return true;
807 }
808 if term.contains("&&") || term.contains("||") {
809 return true;
810 }
811 term.chars().any(|c| {
812 matches!(
813 c,
814 '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
815 )
816 })
817}
818
819#[must_use]
823pub fn strip_stopwords(query: &str) -> String {
824 query
825 .split_whitespace()
826 .filter(|term| !STOPWORDS.contains(&term.to_lowercase().as_str()))
827 .collect::<Vec<_>>()
828 .join(" ")
829}
830
831#[must_use]
835fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
836 stem_tokens(stripped_query)
837}
838
839#[must_use]
843fn stem_tokens(text: &str) -> Vec<String> {
844 let mut tokens: Vec<String> = Vec::new();
845 for term in text
846 .split(|c: char| !c.is_alphanumeric())
847 .filter(|term| term.len() > 1)
848 {
849 let stemmed = simple_stem(&term.to_lowercase());
850 if !tokens.contains(&stemmed) {
851 tokens.push(stemmed);
852 }
853 }
854 tokens
855}
856
857#[must_use]
863fn simple_stem(word: &str) -> String {
864 if word.len() <= 3 {
865 return word.to_string();
866 }
867 for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
869 if let Some(stem) = word.strip_suffix(suffix) {
870 if suffix == "ies" || suffix == "ied" {
872 return format!("{stem}y");
873 }
874 if stem.len() >= 2 {
876 return stem.to_string();
877 }
878 }
879 }
880 word.to_string()
881}
882
883#[must_use]
887fn count_token_hits(content: &str, stripped_query: &str) -> usize {
888 let query_tokens = query_stem_tokens(stripped_query);
889 if query_tokens.is_empty() {
890 return 0;
891 }
892 let content_stems: std::collections::HashSet<String> =
893 stem_tokens(content).into_iter().collect();
894 query_tokens
895 .iter()
896 .filter(|t| content_stems.contains(*t))
897 .count()
898}
899
900#[must_use]
910pub fn sanitize_content_for_index(content: &str) -> Option<String> {
911 if content.trim().is_empty() {
912 return None;
913 }
914 if content.as_bytes().contains(&0) {
915 return None;
916 }
917
918 let total = content.chars().count();
919 if total == 0 {
920 return None;
921 }
922 let printable = content.chars().filter(|c| !c.is_control()).count();
923 if (printable as f32 / total as f32) < MIN_PRINTABLE_RATIO {
924 return None;
925 }
926
927 let cleaned = scrub_text(content);
928 let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
929 if capped.trim().is_empty() {
930 None
931 } else {
932 Some(capped)
933 }
934}
935
936#[must_use]
940pub fn scrub_text(content: &str) -> String {
941 let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
942 for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
943 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
944 out.push(' ');
945 } else {
946 out.push(c);
947 }
948 }
949 out
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use tempfile::tempdir;
956
957 fn open_engine() -> (tempfile::TempDir, SearchEngine) {
958 let tmp = tempdir().unwrap();
959 let engine = SearchEngine::open(tmp.path()).unwrap();
960 (tmp, engine)
961 }
962
963 fn write_incompatible_index(dir: &Path) {
966 std::fs::create_dir_all(dir).unwrap();
967 let mut builder = Schema::builder();
968 builder.add_text_field("legacy", STRING | STORED);
969 let schema = builder.build();
970 let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
971 Index::open_or_create(directory, schema).unwrap();
972 }
973
974 #[test]
975 fn open_migrates_incompatible_schema() {
976 let tmp = tempdir().unwrap();
977 let dir = tmp.path().join("tantivy");
978 write_incompatible_index(&dir);
979
980 let engine = SearchEngine::open(&dir).unwrap();
981 assert!(
982 engine.schema_migrated(),
983 "incompatible schema must trigger migration"
984 );
985
986 let backups: Vec<_> = std::fs::read_dir(tmp.path())
988 .unwrap()
989 .filter_map(std::result::Result::ok)
990 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
991 .collect();
992 assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
993
994 let mut writer = engine.writer().unwrap();
996 engine
997 .add_document(
998 &mut writer,
999 "33333333-3333-3333-3333-333333333333",
1000 "codex",
1001 "fresh index after migration",
1002 &[],
1003 1700000000,
1004 )
1005 .unwrap();
1006 engine.commit(&mut writer).unwrap();
1007 let results = engine.search("fresh index", 10).unwrap();
1008 assert_eq!(results.len(), 1);
1009 }
1010
1011 #[test]
1012 fn writer_lock_error_names_path_and_hint() {
1013 let err = format_writer_lock_error(
1017 "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1018 Path::new("/store/x/tantivy"),
1019 );
1020 assert!(
1021 err.contains("/store/x/tantivy"),
1022 "must name the index path: {err}"
1023 );
1024 assert!(
1025 err.contains("pgrep -af wm"),
1026 "must include the diagnostic hint: {err}"
1027 );
1028 assert!(
1029 err.contains("--readonly"),
1030 "must offer the readonly alternative: {err}"
1031 );
1032
1033 let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1035 assert!(other.starts_with("Tantivy writer: disk full"));
1036 assert!(!other.contains("pgrep"));
1037 }
1038
1039 #[test]
1040 fn open_readonly_rejects_incompatible_schema() {
1041 let tmp = tempdir().unwrap();
1042 let dir = tmp.path().join("tantivy");
1043 write_incompatible_index(&dir);
1044
1045 let err = match SearchEngine::open_readonly(&dir) {
1046 Ok(_) => panic!("read-only open must reject an incompatible schema"),
1047 Err(e) => e,
1048 };
1049 assert!(
1050 format!("{err}").contains("wm reindex"),
1051 "read-only mismatch must point at wm reindex, got: {err}"
1052 );
1053
1054 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1056 .unwrap()
1057 .filter_map(std::result::Result::ok)
1058 .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1059 .collect();
1060 assert!(siblings.is_empty(), "read-only open must not migrate");
1061 }
1062
1063 #[test]
1064 fn reopen_matching_schema_not_migrated() {
1065 let tmp = tempdir().unwrap();
1066 let dir = tmp.path().join("tantivy");
1067 std::fs::create_dir_all(&dir).unwrap();
1068
1069 let first = SearchEngine::open(&dir).unwrap();
1070 assert!(!first.schema_migrated());
1071 drop(first); let second = SearchEngine::open(&dir).unwrap();
1074 assert!(
1075 !second.schema_migrated(),
1076 "matching schema must not migrate"
1077 );
1078 drop(second);
1079
1080 let third = SearchEngine::open_readonly(&dir).unwrap();
1081 assert!(!third.schema_migrated());
1082 }
1083
1084 #[test]
1085 fn index_and_search_basic() {
1086 let (_tmp, engine) = open_engine();
1087 let mut writer = engine.writer().unwrap();
1088
1089 engine
1090 .add_document(
1091 &mut writer,
1092 "11111111-1111-1111-1111-111111111111",
1093 "codex",
1094 "The Rust programming language is fast and safe",
1095 &["rust".into(), "programming".into()],
1096 1700000000,
1097 )
1098 .unwrap();
1099 engine
1100 .add_document(
1101 &mut writer,
1102 "22222222-2222-2222-2222-222222222222",
1103 "codex",
1104 "Python is great for data science",
1105 &["python".into(), "data".into()],
1106 1700000001,
1107 )
1108 .unwrap();
1109 engine.commit(&mut writer).unwrap();
1110
1111 let results = engine.search("rust", 10).unwrap();
1112 assert!(!results.is_empty());
1113 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1114 }
1115
1116 #[test]
1117 fn search_by_tag() {
1118 let (_tmp, engine) = open_engine();
1119 let mut writer = engine.writer().unwrap();
1120
1121 engine
1122 .add_document(
1123 &mut writer,
1124 "11111111-1111-1111-1111-111111111111",
1125 "codex",
1126 "memory about systems",
1127 &["rust".into()],
1128 1700000000,
1129 )
1130 .unwrap();
1131 engine
1132 .add_document(
1133 &mut writer,
1134 "22222222-2222-2222-2222-222222222222",
1135 "codex",
1136 "memory about cooking",
1137 &["food".into()],
1138 1700000001,
1139 )
1140 .unwrap();
1141 engine.commit(&mut writer).unwrap();
1142
1143 let results = engine.search("rust", 10).unwrap();
1144 assert_eq!(results.len(), 1);
1145 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1146 }
1147
1148 #[test]
1149 fn search_filtered_by_galaxy() {
1150 let (_tmp, engine) = open_engine();
1151 let mut writer = engine.writer().unwrap();
1152
1153 engine
1154 .add_document(
1155 &mut writer,
1156 "11111111-1111-1111-1111-111111111111",
1157 "codex",
1158 "important knowledge",
1159 &[],
1160 1700000000,
1161 )
1162 .unwrap();
1163 engine
1164 .add_document(
1165 &mut writer,
1166 "22222222-2222-2222-2222-222222222222",
1167 "research",
1168 "important findings",
1169 &[],
1170 1700000001,
1171 )
1172 .unwrap();
1173 engine.commit(&mut writer).unwrap();
1174
1175 let results = engine
1176 .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1177 .unwrap();
1178 assert_eq!(results.len(), 1);
1179 assert_eq!(results[0].galaxy, "codex");
1180 }
1181
1182 #[test]
1183 fn delete_document_from_index() {
1184 let (_tmp, engine) = open_engine();
1185 let mut writer = engine.writer().unwrap();
1186
1187 engine
1188 .add_document(
1189 &mut writer,
1190 "11111111-1111-1111-1111-111111111111",
1191 "codex",
1192 "deletable content",
1193 &[],
1194 1700000000,
1195 )
1196 .unwrap();
1197 engine.commit(&mut writer).unwrap();
1198
1199 let results = engine.search("deletable", 10).unwrap();
1200 assert_eq!(results.len(), 1);
1201
1202 engine
1203 .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1204 .unwrap();
1205 engine.commit(&mut writer).unwrap();
1206
1207 let results = engine.search("deletable", 10).unwrap();
1208 assert_eq!(results.len(), 0);
1209 }
1210
1211 #[test]
1212 fn search_empty_index() {
1213 let (_tmp, engine) = open_engine();
1214 let results = engine.search("anything", 10).unwrap();
1215 assert!(results.is_empty());
1216 }
1217
1218 #[test]
1219 fn search_ids_returns_uuids() {
1220 let (_tmp, engine) = open_engine();
1221 let mut writer = engine.writer().unwrap();
1222
1223 engine
1224 .add_document(
1225 &mut writer,
1226 "11111111-1111-1111-1111-111111111111",
1227 "codex",
1228 "unique content about rust",
1229 &[],
1230 1700000000,
1231 )
1232 .unwrap();
1233 engine.commit(&mut writer).unwrap();
1234
1235 let ids = engine.search_ids("rust", 10).unwrap();
1236 assert_eq!(ids.len(), 1);
1237 assert_eq!(
1238 ids[0],
1239 uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1240 );
1241 }
1242
1243 #[test]
1246 fn sanitize_leaves_plain_terms_unquoted() {
1247 let result = sanitize_tantivy_query("hello world");
1248 assert_eq!(result, "hello world");
1249 }
1250
1251 #[test]
1252 fn sanitize_drops_punct_only_terms() {
1253 let result = sanitize_tantivy_query("*");
1254 assert_eq!(result, "");
1255 }
1257
1258 #[test]
1259 fn sanitize_escapes_boolean_operators() {
1260 let result = sanitize_tantivy_query("NOT secret");
1261 assert_eq!(result, "\"NOT\" secret");
1262 }
1263
1264 #[test]
1265 fn sanitize_escapes_field_syntax() {
1266 let result = sanitize_tantivy_query("content:secret");
1267 assert_eq!(result, "\"content:secret\"");
1268 }
1269
1270 #[test]
1271 fn sanitize_escapes_quotes() {
1272 let result = sanitize_tantivy_query("test\"injection");
1273 assert!(
1274 result.contains("\\\""),
1275 "embedded quotes should be escaped: {result}"
1276 );
1277 }
1278
1279 #[test]
1280 fn sanitize_empty_returns_empty() {
1281 assert_eq!(sanitize_tantivy_query(""), "");
1282 assert_eq!(sanitize_tantivy_query(" "), "");
1283 }
1284
1285 #[test]
1286 fn sanitize_preserves_alphanumeric() {
1287 let result = sanitize_tantivy_query("rust programming 2024");
1288 assert_eq!(result, "rust programming 2024");
1289 }
1290
1291 #[test]
1292 fn sanitize_preserves_hyphenated_compounds() {
1293 let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1294 assert_eq!(result, "antigravity antigravity-project-test");
1295 }
1296
1297 #[test]
1300 fn strip_stopwords_removes_common_words() {
1301 assert_eq!(
1302 strip_stopwords("smoke test from wmClient"),
1303 "smoke test wmClient"
1304 );
1305 assert_eq!(strip_stopwords("the from and or"), "");
1306 assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1307 assert_eq!(strip_stopwords(""), "");
1308 }
1309
1310 #[test]
1311 fn strip_stopwords_is_case_insensitive() {
1312 assert_eq!(strip_stopwords("FROM The And"), "");
1313 }
1314
1315 #[test]
1318 fn sanitize_content_skips_null_bytes() {
1319 let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1320 assert!(sanitize_content_for_index(content).is_none());
1321 }
1322
1323 #[test]
1324 fn sanitize_content_skips_low_printable_ratio() {
1325 let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1327 assert!(sanitize_content_for_index(content).is_none());
1328 }
1329
1330 #[test]
1331 fn sanitize_content_scrubs_and_caps() {
1332 let content = "clean text\u{01}with one control char";
1334 let cleaned = sanitize_content_for_index(content).unwrap();
1335 assert!(!cleaned.contains('\u{01}'));
1336 assert!(cleaned.starts_with("clean text with one control char"));
1337
1338 let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1339 let capped = sanitize_content_for_index(&long).unwrap();
1340 assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1341 }
1342
1343 #[test]
1344 fn sanitize_content_skips_empty() {
1345 assert!(sanitize_content_for_index("").is_none());
1346 assert!(sanitize_content_for_index(" ").is_none());
1347 }
1348
1349 #[test]
1350 fn scrub_text_replaces_control_chars() {
1351 let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1352 assert_eq!(result, "a b\nc\td e");
1353 }
1354
1355 #[test]
1356 fn add_document_skips_binary_content() {
1357 let (_tmp, engine) = open_engine();
1358 let mut writer = engine.writer().unwrap();
1359
1360 engine
1361 .add_document(
1362 &mut writer,
1363 "11111111-1111-1111-1111-111111111111",
1364 "codex",
1365 "\u{00}\u{01}\u{02}raw serialized bytes",
1366 &[],
1367 1700000000,
1368 )
1369 .unwrap();
1370 engine
1371 .add_document(
1372 &mut writer,
1373 "22222222-2222-2222-2222-222222222222",
1374 "codex",
1375 "clean searchable text",
1376 &[],
1377 1700000001,
1378 )
1379 .unwrap();
1380 engine.commit(&mut writer).unwrap();
1381
1382 let results = engine.search("serialized", 10).unwrap();
1384 assert!(results.is_empty(), "binary content must not be indexed");
1385
1386 let results = engine.search("clean", 10).unwrap();
1387 assert_eq!(results.len(), 1);
1388 assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1389 }
1390
1391 fn index_alpha_pair(engine: &SearchEngine) {
1394 let mut writer = engine.writer().unwrap();
1395 engine
1398 .add_document(
1399 &mut writer,
1400 "11111111-1111-1111-1111-111111111111",
1401 "codex",
1402 "alpha",
1403 &[],
1404 1700000000,
1405 )
1406 .unwrap();
1407 let filler = format!("alpha {}", "zzz ".repeat(400));
1408 engine
1409 .add_document(
1410 &mut writer,
1411 "22222222-2222-2222-2222-222222222222",
1412 "codex",
1413 &filler,
1414 &[],
1415 1700000001,
1416 )
1417 .unwrap();
1418 engine.commit(&mut writer).unwrap();
1419 }
1420
1421 #[test]
1422 fn search_absolute_min_score_filters_weak_matches() {
1423 let (_tmp, engine) = open_engine();
1424 index_alpha_pair(&engine);
1425
1426 let base = engine.search("alpha", 10).unwrap();
1427 assert_eq!(base.len(), 2);
1428 let (hi, lo) = if base[0].score >= base[1].score {
1429 (base[0].score, base[1].score)
1430 } else {
1431 (base[1].score, base[0].score)
1432 };
1433 assert!(
1434 hi > lo,
1435 "short doc should outscore long doc (hi={hi}, lo={lo})"
1436 );
1437 let mid = f32::midpoint(hi, lo);
1438
1439 let opts = SearchOptions {
1440 limit: 10,
1441 min_score: Some(mid),
1442 ..SearchOptions::default()
1443 };
1444 let filtered = engine.search_opt("alpha", &opts).unwrap();
1445 assert_eq!(filtered.len(), 1);
1446 assert!((filtered[0].score - hi).abs() < 1e-3);
1447 }
1448
1449 #[test]
1450 fn search_relative_floor_filters_weak_matches() {
1451 let (_tmp, engine) = open_engine();
1452 index_alpha_pair(&engine);
1453
1454 let opts = SearchOptions {
1455 limit: 10,
1456 relative_floor: Some(0.5),
1457 ..SearchOptions::default()
1458 };
1459 let filtered = engine.search_opt("alpha", &opts).unwrap();
1460 assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1461 assert_eq!(
1462 filtered[0].memory_id,
1463 "11111111-1111-1111-1111-111111111111"
1464 );
1465 assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1466 }
1467
1468 #[test]
1469 fn search_all_results_normalized() {
1470 let (_tmp, engine) = open_engine();
1471 index_alpha_pair(&engine);
1472
1473 let results = engine.search("alpha", 10).unwrap();
1474 assert_eq!(results.len(), 2);
1475 assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1476 for r in &results[1..] {
1477 assert!(r.normalized_score <= 1.0);
1478 assert!(r.normalized_score > 0.0);
1479 }
1480 }
1481
1482 #[test]
1483 fn search_stemming_matches_morphological_variants() {
1484 let (_tmp, engine) = open_engine();
1487 let mut writer = engine.writer().unwrap();
1488
1489 engine
1490 .add_document(
1491 &mut writer,
1492 "11111111-1111-1111-1111-111111111111",
1493 "codex",
1494 "I graduated with a degree in Business Administration",
1495 &[],
1496 1700000000,
1497 )
1498 .unwrap();
1499 engine.commit(&mut writer).unwrap();
1500
1501 let results = engine.search("graduate", 10).unwrap();
1503 assert_eq!(results.len(), 1);
1504 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1505
1506 let results = engine.search("degrees", 10).unwrap();
1508 assert_eq!(results.len(), 1);
1509 }
1510
1511 #[test]
1512 fn search_incident_query_returns_only_relevant() {
1513 let (_tmp, engine) = open_engine();
1516 let mut writer = engine.writer().unwrap();
1517
1518 let smoke_id = "11111111-1111-1111-1111-111111111111";
1519 engine
1520 .add_document(
1521 &mut writer,
1522 smoke_id,
1523 "codex",
1524 "smoke test from wmClient: verify recall works",
1525 &[],
1526 1700000000,
1527 )
1528 .unwrap();
1529 let unrelated = [
1530 "NES Evolution and Impact: a history of the console wars",
1531 "Insights on The Gateless Gate: koans and zen practice",
1532 "What the tweet is really saying: a thread analysis",
1533 ];
1534 for (i, content) in (1i64..).zip(unrelated.iter()) {
1535 engine
1536 .add_document(
1537 &mut writer,
1538 &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1539 "codex",
1540 content,
1541 &[],
1542 1700000000 + i,
1543 )
1544 .unwrap();
1545 }
1546 engine.commit(&mut writer).unwrap();
1547
1548 let results = engine.search("smoke test from wmClient", 20).unwrap();
1549 assert_eq!(
1550 results.len(),
1551 1,
1552 "only the smoke memory should match: {results:?}"
1553 );
1554 assert_eq!(results[0].memory_id, smoke_id);
1555 assert!(results[0].content.contains("smoke test"));
1556 }
1557
1558 #[test]
1559 fn search_project_compound_query() {
1560 let (_tmp, engine) = open_engine();
1562 let mut writer = engine.writer().unwrap();
1563
1564 engine
1565 .add_document(
1566 &mut writer,
1567 "11111111-1111-1111-1111-111111111111",
1568 "codex",
1569 "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1570 &["project_antigravity-project-test".into()],
1571 1700000000,
1572 )
1573 .unwrap();
1574 engine.commit(&mut writer).unwrap();
1575
1576 let results = engine
1577 .search("antigravity antigravity-project-test", 10)
1578 .unwrap();
1579 assert!(
1580 !results.is_empty(),
1581 "project compound query must match the antigravity memory"
1582 );
1583 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1584 }
1585
1586 #[test]
1587 fn or_default_filters_partial_matches_via_coverage() {
1588 let (_tmp, engine) = open_engine();
1589 let mut writer = engine.writer().unwrap();
1590
1591 engine
1592 .add_document(
1593 &mut writer,
1594 "11111111-1111-1111-1111-111111111111",
1595 "codex",
1596 "alpha beta gamma delta",
1597 &[],
1598 1700000000,
1599 )
1600 .unwrap();
1601 engine
1602 .add_document(
1603 &mut writer,
1604 "22222222-2222-2222-2222-222222222222",
1605 "codex",
1606 "alpha only here",
1607 &[],
1608 1700000001,
1609 )
1610 .unwrap();
1611 engine.commit(&mut writer).unwrap();
1612
1613 let results = engine.search("alpha beta gamma", 10).unwrap();
1616 assert_eq!(results.len(), 1);
1617 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1618
1619 let results = engine.search("alpha beta", 10).unwrap();
1621 assert_eq!(results.len(), 2);
1622 }
1623
1624 #[test]
1625 fn coverage_is_case_insensitive() {
1626 let (_tmp, engine) = open_engine();
1627 let mut writer = engine.writer().unwrap();
1628 engine
1629 .add_document(
1630 &mut writer,
1631 "11111111-1111-1111-1111-111111111111",
1632 "codex",
1633 "Smoke Test for wmClient integration",
1634 &[],
1635 1700000000,
1636 )
1637 .unwrap();
1638 engine
1639 .add_document(
1640 &mut writer,
1641 "22222222-2222-2222-2222-222222222222",
1642 "codex",
1643 "test only here",
1644 &[],
1645 1700000001,
1646 )
1647 .unwrap();
1648 engine.commit(&mut writer).unwrap();
1649
1650 let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1655 assert_eq!(results.len(), 1);
1656 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1657 }
1658
1659 #[test]
1660 fn coverage_matches_stemmed_variants() {
1661 let (_tmp, engine) = open_engine();
1664 let mut writer = engine.writer().unwrap();
1665 engine
1666 .add_document(
1667 &mut writer,
1668 "11111111-1111-1111-1111-111111111111",
1669 "codex",
1670 "I graduated with a degree in Business Administration",
1671 &[],
1672 1700000000,
1673 )
1674 .unwrap();
1675 engine
1676 .add_document(
1677 &mut writer,
1678 "22222222-2222-2222-2222-222222222222",
1679 "codex",
1680 "degree only here",
1681 &[],
1682 1700000001,
1683 )
1684 .unwrap();
1685 engine.commit(&mut writer).unwrap();
1686
1687 let results = engine.search("graduate degree", 10).unwrap();
1693 assert_eq!(results.len(), 2);
1694 assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1695 }
1696
1697 #[test]
1698 fn coverage_normalizes_possessives_and_punctuation() {
1699 let query = "buy sister's birthday gift";
1700 assert_eq!(
1701 query_stem_tokens(query),
1702 ["buy", "sister", "birthday", "gift"]
1703 );
1704 assert_eq!(
1705 count_token_hits("I bought a dress for my sister birthday", query),
1706 2
1707 );
1708 }
1709
1710 #[test]
1711 fn search_stopword_only_query_returns_nothing() {
1712 let (_tmp, engine) = open_engine();
1713 let mut writer = engine.writer().unwrap();
1714 engine
1715 .add_document(
1716 &mut writer,
1717 "11111111-1111-1111-1111-111111111111",
1718 "codex",
1719 "some ordinary text",
1720 &[],
1721 1700000000,
1722 )
1723 .unwrap();
1724 engine.commit(&mut writer).unwrap();
1725
1726 let results = engine.search("the from and or", 10).unwrap();
1727 assert!(results.is_empty());
1728 }
1729
1730 #[test]
1731 fn wildcard_query_doesnt_match_all() {
1732 let (_tmp, engine) = open_engine();
1733 let mut writer = engine.writer().unwrap();
1734
1735 engine
1736 .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
1737 .unwrap();
1738 engine
1739 .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
1740 .unwrap();
1741 engine.commit(&mut writer).unwrap();
1742
1743 let results = engine.search("*", 10).unwrap();
1745 assert!(
1748 results.is_empty(),
1749 "wildcard query should not match all documents after sanitization"
1750 );
1751 }
1752
1753 #[test]
1754 fn field_syntax_query_doesnt_access_other_fields() {
1755 let (_tmp, engine) = open_engine();
1756 let mut writer = engine.writer().unwrap();
1757
1758 engine
1760 .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
1761 .unwrap();
1762 engine.commit(&mut writer).unwrap();
1763
1764 let results = engine.search("galaxy:secret", 10).unwrap();
1766 assert!(
1769 results.is_empty(),
1770 "field syntax injection should not access non-searchable fields"
1771 );
1772 }
1773
1774 #[test]
1775 fn boolean_operator_doesnt_bypass_search() {
1776 let (_tmp, engine) = open_engine();
1777 let mut writer = engine.writer().unwrap();
1778
1779 engine
1780 .add_document(
1781 &mut writer,
1782 "uuid-1",
1783 "codex",
1784 "important secret data",
1785 &[],
1786 1000,
1787 )
1788 .unwrap();
1789 engine.commit(&mut writer).unwrap();
1790
1791 let results = engine.search("AND secret", 10).unwrap();
1795 assert_eq!(results.len(), 1);
1796
1797 let results = engine.search("AND OR NOT", 10).unwrap();
1800 assert!(
1801 results.is_empty(),
1802 "operator-only query must not bypass search"
1803 );
1804 }
1805}