1use serde::{Deserialize, Serialize};
2use std::{
3 fs,
4 path::{Path, PathBuf},
5 sync::{Arc, Mutex, MutexGuard},
6};
7use tantivy::{
8 collector::TopDocs, query::QueryParser, schema::*, snippet::SnippetGenerator, Index,
9 IndexReader, IndexWriter, TantivyDocument, TantivyError,
10};
11use tantivy_jieba::JiebaTokenizer;
12use walkdir::WalkDir;
13
14#[derive(Deserialize)]
16pub struct SearchQuery {
17 pub q: String,
18}
19
20#[derive(Serialize)]
22pub struct SearchResult {
23 pub file_path: String,
24 pub file_name: String,
25 pub title: String,
26 pub snippet: String,
27}
28
29pub struct SearchIndex {
30 index: Index,
31 reader: IndexReader,
32 writer: Arc<Mutex<IndexWriter>>,
33 #[allow(dead_code)]
34 schema: Schema,
35 field_path: Field,
36 field_file_name: Field,
37 field_title: Field,
38 field_content: Field,
39 start_dir: PathBuf,
40}
41
42impl SearchIndex {
43 pub fn new(start_dir: &Path) -> tantivy::Result<Self> {
44 let mut schema_builder = Schema::builder();
46
47 let text_options = TextOptions::default()
48 .set_indexing_options(
49 TextFieldIndexing::default()
50 .set_tokenizer("jieba")
51 .set_index_option(IndexRecordOption::WithFreqsAndPositions),
52 )
53 .set_stored();
54
55 let field_path = schema_builder.add_text_field("path", STRING | STORED);
57 let field_file_name = schema_builder.add_text_field("file_name", text_options.clone());
58 let field_title = schema_builder.add_text_field("title", text_options.clone());
59 let field_content = schema_builder.add_text_field("content", text_options);
60
61 let schema = schema_builder.build();
62
63 let index = Index::create_in_ram(schema.clone());
65
66 index.tokenizers().register("jieba", JiebaTokenizer {});
68
69 let writer = index.writer(50_000_000)?;
71 let reader = index.reader()?;
72
73 let search_index = Self {
74 index,
75 reader,
76 writer: Arc::new(Mutex::new(writer)),
77 schema,
78 field_path,
79 field_file_name,
80 field_title,
81 field_content,
82 start_dir: start_dir.to_path_buf(),
83 };
84
85 search_index.index_directory(start_dir)?;
87
88 Ok(search_index)
89 }
90
91 fn writer(&self) -> tantivy::Result<MutexGuard<'_, IndexWriter>> {
96 self.writer.lock().map_err(|err| {
97 TantivyError::SystemError(format!("search index writer mutex poisoned: {err}"))
98 })
99 }
100
101 fn index_directory(&self, dir: &Path) -> tantivy::Result<()> {
102 use rayon::prelude::*;
103
104 tracing::info!("indexing markdown files in {dir:?}");
105
106 let paths: Vec<PathBuf> = WalkDir::new(dir)
107 .into_iter()
108 .filter_map(Result::ok)
109 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
110 .map(|e| e.into_path())
111 .collect();
112
113 let docs: Vec<TantivyDocument> = paths
119 .par_iter()
120 .filter_map(|path| {
121 let content = fs::read_to_string(path).ok()?;
122 Some(self.build_document(path, &content))
123 })
124 .collect();
125
126 {
131 let mut writer = self.writer()?;
132 for doc in docs {
133 writer.add_document(doc)?;
134 }
135 writer.commit()?;
136 }
137
138 self.reader.reload()?;
139 tracing::info!("indexing complete");
140
141 Ok(())
142 }
143
144 fn build_document(&self, path: &Path, content: &str) -> TantivyDocument {
148 let relative_path = path
150 .strip_prefix(&self.start_dir)
151 .unwrap_or(path)
152 .to_string_lossy()
153 .to_string();
154
155 let file_name = path
156 .file_stem()
157 .and_then(|s| s.to_str())
158 .unwrap_or("")
159 .to_string();
160
161 let title = content
163 .lines()
164 .find(|line| line.starts_with('#'))
165 .map(|line| line.trim_start_matches('#').trim().to_string())
166 .unwrap_or_else(|| file_name.clone());
167
168 let mut doc = TantivyDocument::default();
169 doc.add_text(self.field_path, &relative_path);
170 doc.add_text(self.field_file_name, &file_name);
171 doc.add_text(self.field_title, &title);
172 doc.add_text(self.field_content, content);
173 doc
174 }
175
176 pub fn search(&self, query_str: &str, limit: usize) -> tantivy::Result<Vec<SearchResult>> {
177 let searcher = self.reader.searcher();
178
179 let query_parser = QueryParser::for_index(
181 &self.index,
182 vec![self.field_file_name, self.field_title, self.field_content],
183 );
184
185 let query = query_parser.parse_query(query_str)?;
186 let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
187
188 let mut results = Vec::new();
189 let snippet_generator = SnippetGenerator::create(&searcher, &query, self.field_content)?;
190
191 for (_score, doc_address) in top_docs {
192 let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
193
194 let file_path = retrieved_doc
195 .get_first(self.field_path)
196 .and_then(|v| v.as_str())
197 .unwrap_or("")
198 .to_string();
199
200 let file_name = retrieved_doc
201 .get_first(self.field_file_name)
202 .and_then(|v| v.as_str())
203 .unwrap_or("")
204 .to_string();
205
206 let title = retrieved_doc
207 .get_first(self.field_title)
208 .and_then(|v| v.as_str())
209 .unwrap_or("")
210 .to_string();
211
212 let snippet = snippet_generator.snippet_from_doc(&retrieved_doc);
213 let snippet_html = snippet.to_html();
214
215 results.push(SearchResult {
216 file_path,
217 file_name,
218 title,
219 snippet: snippet_html,
220 });
221 }
222
223 Ok(results)
224 }
225
226 pub fn update_file(&self, path: &Path) -> tantivy::Result<()> {
227 if path.extension().is_none_or(|ext| ext != "md") {
228 return Ok(());
229 }
230
231 let content = fs::read_to_string(path)?;
232 let relative_path = path
234 .strip_prefix(&self.start_dir)
235 .unwrap_or(path)
236 .to_string_lossy()
237 .to_string();
238
239 let file_name = path
240 .file_stem()
241 .and_then(|s| s.to_str())
242 .unwrap_or("")
243 .to_string();
244
245 let title = content
246 .lines()
247 .find(|line| line.starts_with('#'))
248 .map(|line| line.trim_start_matches('#').trim().to_string())
249 .unwrap_or_else(|| file_name.clone());
250
251 let mut doc = TantivyDocument::default();
252 doc.add_text(self.field_path, &relative_path);
253 doc.add_text(self.field_file_name, &file_name);
254 doc.add_text(self.field_title, &title);
255 doc.add_text(self.field_content, &content);
256
257 {
259 let mut writer = self.writer()?;
260 let term = Term::from_field_text(self.field_path, &relative_path);
261 writer.delete_term(term);
262 writer.add_document(doc)?;
263 writer.commit()?;
264 }
265
266 self.reader.reload()?;
268
269 tracing::debug!("updated index: {}", relative_path);
270 Ok(())
271 }
272
273 pub fn delete_file(&self, path: &Path) -> tantivy::Result<()> {
274 let relative_path = path
276 .strip_prefix(&self.start_dir)
277 .unwrap_or(path)
278 .to_string_lossy()
279 .to_string();
280
281 {
282 let mut writer = self.writer()?;
283 let term = Term::from_field_text(self.field_path, &relative_path);
284 writer.delete_term(term);
285 writer.commit()?;
286 }
287
288 self.reader.reload()?;
290
291 tracing::debug!("removed from index: {}", relative_path);
292 Ok(())
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::fs;
300 use tempfile::TempDir;
301
302 fn create_test_file(dir: &Path, name: &str, content: &str) -> std::io::Result<()> {
303 let file_path = dir.join(name);
304 fs::write(file_path, content)
305 }
306
307 #[test]
308 fn test_search_index_creation() {
309 let temp_dir = TempDir::new().unwrap();
310 let dir_path = temp_dir.path();
311
312 create_test_file(dir_path, "test1.md", "# Test Title\nThis is test content.").unwrap();
314 create_test_file(dir_path, "test2.md", "# Another Title\nMore content here.").unwrap();
315
316 let index = SearchIndex::new(dir_path).unwrap();
317
318 assert!(index.reader.searcher().num_docs() >= 2);
320 }
321
322 #[test]
323 fn test_search_basic() {
324 let temp_dir = TempDir::new().unwrap();
325 let dir_path = temp_dir.path();
326
327 create_test_file(
328 dir_path,
329 "rust.md",
330 "# Rust Programming\nRust is a systems programming language.",
331 )
332 .unwrap();
333 create_test_file(
334 dir_path,
335 "python.md",
336 "# Python Guide\nPython is easy to learn.",
337 )
338 .unwrap();
339
340 let index = SearchIndex::new(dir_path).unwrap();
341
342 let results = index.search("Rust", 10).unwrap();
343 assert_eq!(results.len(), 1);
344 assert!(results[0].title.contains("Rust"));
345 }
346
347 #[test]
348 fn test_search_chinese() {
349 let temp_dir = TempDir::new().unwrap();
350 let dir_path = temp_dir.path();
351
352 create_test_file(
353 dir_path,
354 "chinese.md",
355 "# 中文测试\n这是一个中文搜索测试文档。",
356 )
357 .unwrap();
358 create_test_file(
359 dir_path,
360 "english.md",
361 "# English Test\nThis is an English document.",
362 )
363 .unwrap();
364
365 let index = SearchIndex::new(dir_path).unwrap();
366
367 let results = index.search("中文", 10).unwrap();
369 assert_eq!(results.len(), 1);
370 assert!(results[0].title.contains("中文"));
371
372 let results = index.search("测试", 10).unwrap();
374 assert_eq!(results.len(), 1);
375 }
376
377 #[test]
378 fn test_search_multi_field() {
379 let temp_dir = TempDir::new().unwrap();
380 let dir_path = temp_dir.path();
381
382 create_test_file(
383 dir_path,
384 "hello.md",
385 "# Hello World\nContent about greetings.",
386 )
387 .unwrap();
388 create_test_file(dir_path, "world.md", "# Universe\nThe world is vast.").unwrap();
389 create_test_file(dir_path, "test.md", "# Testing\nAnother document.").unwrap();
390
391 let index = SearchIndex::new(dir_path).unwrap();
392
393 let results = index.search("greetings", 10).unwrap();
395 assert_eq!(results.len(), 1);
396
397 let results = index.search("Hello", 10).unwrap();
399 assert!(!results.is_empty());
400 }
401
402 #[test]
403 fn test_update_file() {
404 let temp_dir = TempDir::new().unwrap();
405 let dir_path = temp_dir.path();
406 let file_path = dir_path.join("update.md");
407
408 create_test_file(dir_path, "update.md", "# Original Title\nOriginal content.").unwrap();
410
411 let index = SearchIndex::new(dir_path).unwrap();
412
413 let results = index.search("Original", 10).unwrap();
415 assert_eq!(results.len(), 1);
416
417 fs::write(&file_path, "# Updated Title\nUpdated content.").unwrap();
419 index.update_file(&file_path).unwrap();
420
421 let results = index.search("Updated", 10).unwrap();
423 assert_eq!(results.len(), 1);
424
425 let results = index.search("Original", 10).unwrap();
427 assert_eq!(results.len(), 0);
428 }
429
430 #[test]
431 fn test_delete_file() {
432 let temp_dir = TempDir::new().unwrap();
433 let dir_path = temp_dir.path();
434 let file_path = dir_path.join("delete.md");
435
436 create_test_file(dir_path, "delete.md", "# Delete Me\nThis will be deleted.").unwrap();
437
438 let index = SearchIndex::new(dir_path).unwrap();
439
440 let results = index.search("Delete", 10).unwrap();
442 assert_eq!(results.len(), 1);
443
444 index.delete_file(&file_path).unwrap();
446
447 let results = index.search("Delete", 10).unwrap();
449 assert_eq!(results.len(), 0);
450 }
451
452 #[test]
453 fn test_search_snippet_generation() {
454 let temp_dir = TempDir::new().unwrap();
455 let dir_path = temp_dir.path();
456
457 create_test_file(
458 dir_path,
459 "snippet.md",
460 "# Snippet Test\nThis document contains important information about snippets. Snippets are useful."
461 ).unwrap();
462
463 let index = SearchIndex::new(dir_path).unwrap();
464
465 let results = index.search("snippets", 10).unwrap();
466 assert_eq!(results.len(), 1);
467 assert!(!results[0].snippet.is_empty());
468 assert!(results[0].snippet.contains("<b>") || results[0].snippet.contains("snippet"));
470 }
471
472 #[test]
473 fn test_ignore_non_markdown_files() {
474 let temp_dir = TempDir::new().unwrap();
475 let dir_path = temp_dir.path();
476
477 create_test_file(dir_path, "test.md", "# Markdown\nThis is markdown.").unwrap();
478 create_test_file(dir_path, "test.txt", "# Not Markdown\nThis is text.").unwrap();
479
480 let index = SearchIndex::new(dir_path).unwrap();
481
482 let results = index.search("Markdown", 10).unwrap();
484 assert_eq!(results.len(), 1);
485
486 let results = index.search("text", 10).unwrap();
488 assert_eq!(results.len(), 0);
489 }
490
491 #[test]
492 fn test_empty_query() {
493 let temp_dir = TempDir::new().unwrap();
494 let dir_path = temp_dir.path();
495
496 create_test_file(dir_path, "test.md", "# Test\nContent").unwrap();
497
498 let index = SearchIndex::new(dir_path).unwrap();
499
500 let results = index.search("", 10);
502 assert!(results.is_err() || results.unwrap().is_empty());
503 }
504
505 #[test]
506 fn test_title_extraction() {
507 let temp_dir = TempDir::new().unwrap();
508 let dir_path = temp_dir.path();
509
510 create_test_file(dir_path, "with-title.md", "# Main Title\nContent").unwrap();
512
513 create_test_file(dir_path, "no-title.md", "Just content without heading").unwrap();
515
516 let index = SearchIndex::new(dir_path).unwrap();
517
518 let results = index.search("Main", 10).unwrap();
519 assert_eq!(results.len(), 1);
520 assert_eq!(results[0].title, "Main Title");
521
522 let results = index.search("no-title", 10).unwrap();
523 assert_eq!(results.len(), 1);
524 assert_eq!(results[0].title, "no-title");
525 }
526
527 #[test]
528 fn test_search_limit() {
529 let temp_dir = TempDir::new().unwrap();
530 let dir_path = temp_dir.path();
531
532 for i in 0..10 {
534 create_test_file(
535 dir_path,
536 &format!("file{}.md", i),
537 "# Document\nCommon content",
538 )
539 .unwrap();
540 }
541
542 let index = SearchIndex::new(dir_path).unwrap();
543
544 let results = index.search("Common", 5).unwrap();
546 assert_eq!(results.len(), 5);
547
548 let results = index.search("Common", 20).unwrap();
549 assert_eq!(results.len(), 10);
550 }
551
552 #[test]
553 fn test_subdirectory_relative_paths() {
554 let temp_dir = TempDir::new().unwrap();
555 let sub = temp_dir.path().join("notes").join("deep");
556 fs::create_dir_all(&sub).unwrap();
557 create_test_file(&sub, "nested.md", "# Nested\nDeep content here").unwrap();
558
559 let index = SearchIndex::new(temp_dir.path()).unwrap();
560 let results = index.search("Deep", 10).unwrap();
561 assert_eq!(results.len(), 1);
562 let path = &results[0].file_path;
564 assert!(
565 path.starts_with("notes"),
566 "expected relative path, got: {path}"
567 );
568 assert!(
569 path.contains("nested"),
570 "expected file name in path, got: {path}"
571 );
572 assert!(
573 !path.starts_with('/'),
574 "path should be relative, got: {path}"
575 );
576 }
577
578 #[test]
579 fn test_update_file_ignores_non_markdown() {
580 let temp_dir = TempDir::new().unwrap();
581 create_test_file(temp_dir.path(), "test.md", "# Original\nMarkdown").unwrap();
582 let index = SearchIndex::new(temp_dir.path()).unwrap();
583
584 let txt_path = temp_dir.path().join("notes.txt");
586 fs::write(&txt_path, "Some text content").unwrap();
587 index.update_file(&txt_path).unwrap();
589
590 let results = index.search("Some text content", 10).unwrap();
592 assert!(results.is_empty());
593 }
594
595 #[test]
596 fn test_update_file_no_extension() {
597 let temp_dir = TempDir::new().unwrap();
598 create_test_file(temp_dir.path(), "test.md", "# Doc\nContent").unwrap();
599 let index = SearchIndex::new(temp_dir.path()).unwrap();
600
601 let no_ext = temp_dir.path().join("README");
602 fs::write(&no_ext, "Plain text").unwrap();
603 index.update_file(&no_ext).unwrap();
604
605 let results = index.search("Plain text", 10).unwrap();
606 assert!(results.is_empty());
607 }
608
609 #[test]
614 fn test_index_directory_parallel_completeness() {
615 let temp_dir = TempDir::new().unwrap();
616 let dir_path = temp_dir.path();
617
618 const N: usize = 50;
619 for i in 0..N {
620 let body_repeat = (i % 7) + 1;
624 let body = "lorem ipsum dolor sit amet ".repeat(body_repeat * 4);
625 let content =
626 format!("# Doc {i}\nmarker_token_{i} is unique to this file.\n\n{body}\n");
627 create_test_file(dir_path, &format!("file_{i:02}.md", i = i), &content).unwrap();
628 }
629
630 let index = SearchIndex::new(dir_path).unwrap();
631
632 assert_eq!(
634 index.reader.searcher().num_docs(),
635 N as u64,
636 "parallel indexing dropped documents"
637 );
638
639 for i in [0usize, 7, 23, 49] {
643 let needle = format!("marker_token_{i}");
644 let results = index.search(&needle, 10).unwrap();
645 assert_eq!(
646 results.len(),
647 1,
648 "expected exactly one hit for `{needle}`, got {}",
649 results.len()
650 );
651 }
652
653 let results = index.search("Doc", 100).unwrap();
655 assert_eq!(results.len(), N);
656 }
657
658 #[test]
664 fn test_writer_poison_returns_error_not_panic() {
665 let temp_dir = TempDir::new().unwrap();
666 create_test_file(temp_dir.path(), "seed.md", "# Seed\nseed content").unwrap();
667
668 let index = SearchIndex::new(temp_dir.path()).unwrap();
669
670 let writer_handle = Arc::clone(&index.writer);
674 let poisoner = std::thread::spawn(move || {
675 let _guard = writer_handle.lock().unwrap();
676 panic!("intentional poison for test");
677 });
678 let join_result = poisoner.join();
681 assert!(
682 join_result.is_err(),
683 "poisoner thread was supposed to panic"
684 );
685
686 let new_file = temp_dir.path().join("new.md");
689 fs::write(&new_file, "# After Poison\nshould not panic").unwrap();
690 let result = index.update_file(&new_file);
691 assert!(
692 result.is_err(),
693 "update_file should report poisoned writer as an error"
694 );
695
696 let delete_result = index.delete_file(&new_file);
697 assert!(
698 delete_result.is_err(),
699 "delete_file should report poisoned writer as an error"
700 );
701 }
702}