1use serde::{Deserialize, Serialize};
2use std::{
3 fs,
4 path::{Path, PathBuf},
5 sync::{Arc, Mutex},
6};
7use tantivy::{
8 collector::TopDocs, query::QueryParser, schema::*, snippet::SnippetGenerator, Index,
9 IndexReader, IndexWriter, TantivyDocument,
10};
11use tantivy_jieba::JiebaTokenizer;
12use walkdir::WalkDir;
13
14#[derive(Deserialize)]
15pub struct SearchQuery {
16 pub q: String,
17}
18
19#[derive(Serialize)]
20pub struct SearchResult {
21 pub file_path: String,
22 pub file_name: String,
23 pub title: String,
24 pub snippet: String,
25}
26
27pub struct SearchIndex {
28 index: Index,
29 reader: IndexReader,
30 writer: Arc<Mutex<IndexWriter>>,
31 #[allow(dead_code)]
32 schema: Schema,
33 field_path: Field,
34 field_file_name: Field,
35 field_title: Field,
36 field_content: Field,
37 start_dir: PathBuf,
38}
39
40impl SearchIndex {
41 pub fn new(start_dir: &Path) -> tantivy::Result<Self> {
42 let mut schema_builder = Schema::builder();
44
45 let text_options = TextOptions::default()
46 .set_indexing_options(
47 TextFieldIndexing::default()
48 .set_tokenizer("jieba")
49 .set_index_option(IndexRecordOption::WithFreqsAndPositions),
50 )
51 .set_stored();
52
53 let field_path = schema_builder.add_text_field("path", STRING | STORED);
55 let field_file_name = schema_builder.add_text_field("file_name", text_options.clone());
56 let field_title = schema_builder.add_text_field("title", text_options.clone());
57 let field_content = schema_builder.add_text_field("content", text_options);
58
59 let schema = schema_builder.build();
60
61 let index = Index::create_in_ram(schema.clone());
63
64 index.tokenizers().register("jieba", JiebaTokenizer {});
66
67 let writer = index.writer(50_000_000)?;
69 let reader = index.reader()?;
70
71 let search_index = Self {
72 index,
73 reader,
74 writer: Arc::new(Mutex::new(writer)),
75 schema,
76 field_path,
77 field_file_name,
78 field_title,
79 field_content,
80 start_dir: start_dir.to_path_buf(),
81 };
82
83 search_index.index_directory(start_dir)?;
85
86 Ok(search_index)
87 }
88
89 fn index_directory(&self, dir: &Path) -> tantivy::Result<()> {
90 use rayon::prelude::*;
91
92 println!("Indexing markdown files in {dir:?}...");
93
94 let paths: Vec<PathBuf> = WalkDir::new(dir)
95 .into_iter()
96 .filter_map(Result::ok)
97 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
98 .map(|e| e.into_path())
99 .collect();
100
101 paths.par_iter().for_each(|path| {
102 if let Ok(content) = fs::read_to_string(path) {
103 let _ = self.index_file(path, &content);
104 }
105 });
106
107 let mut writer = self.writer.lock().unwrap();
108 writer.commit()?;
109 drop(writer);
110
111 self.reader.reload()?;
112 println!("Indexing complete!");
113
114 Ok(())
115 }
116
117 fn index_file(&self, path: &Path, content: &str) -> tantivy::Result<()> {
118 let relative_path = path
120 .strip_prefix(&self.start_dir)
121 .unwrap_or(path)
122 .to_string_lossy()
123 .to_string();
124
125 let file_name = path
126 .file_stem()
127 .and_then(|s| s.to_str())
128 .unwrap_or("")
129 .to_string();
130
131 let title = content
133 .lines()
134 .find(|line| line.starts_with('#'))
135 .map(|line| line.trim_start_matches('#').trim().to_string())
136 .unwrap_or_else(|| file_name.clone());
137
138 let mut doc = TantivyDocument::default();
139 doc.add_text(self.field_path, &relative_path);
140 doc.add_text(self.field_file_name, &file_name);
141 doc.add_text(self.field_title, &title);
142 doc.add_text(self.field_content, content);
143
144 let writer = self.writer.lock().unwrap();
145 writer.add_document(doc)?;
146
147 Ok(())
148 }
149
150 pub fn search(&self, query_str: &str, limit: usize) -> tantivy::Result<Vec<SearchResult>> {
151 let searcher = self.reader.searcher();
152
153 let query_parser = QueryParser::for_index(
155 &self.index,
156 vec![self.field_file_name, self.field_title, self.field_content],
157 );
158
159 let query = query_parser.parse_query(query_str)?;
160 let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
161
162 let mut results = Vec::new();
163 let snippet_generator = SnippetGenerator::create(&searcher, &query, self.field_content)?;
164
165 for (_score, doc_address) in top_docs {
166 let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
167
168 let file_path = retrieved_doc
169 .get_first(self.field_path)
170 .and_then(|v| v.as_str())
171 .unwrap_or("")
172 .to_string();
173
174 let file_name = retrieved_doc
175 .get_first(self.field_file_name)
176 .and_then(|v| v.as_str())
177 .unwrap_or("")
178 .to_string();
179
180 let title = retrieved_doc
181 .get_first(self.field_title)
182 .and_then(|v| v.as_str())
183 .unwrap_or("")
184 .to_string();
185
186 let snippet = snippet_generator.snippet_from_doc(&retrieved_doc);
187 let snippet_html = snippet.to_html();
188
189 results.push(SearchResult {
190 file_path,
191 file_name,
192 title,
193 snippet: snippet_html,
194 });
195 }
196
197 Ok(results)
198 }
199
200 pub fn update_file(&self, path: &Path) -> tantivy::Result<()> {
201 if path.extension().is_none_or(|ext| ext != "md") {
202 return Ok(());
203 }
204
205 let content = fs::read_to_string(path)?;
206 let relative_path = path
208 .strip_prefix(&self.start_dir)
209 .unwrap_or(path)
210 .to_string_lossy()
211 .to_string();
212
213 let file_name = path
214 .file_stem()
215 .and_then(|s| s.to_str())
216 .unwrap_or("")
217 .to_string();
218
219 let title = content
220 .lines()
221 .find(|line| line.starts_with('#'))
222 .map(|line| line.trim_start_matches('#').trim().to_string())
223 .unwrap_or_else(|| file_name.clone());
224
225 let mut doc = TantivyDocument::default();
226 doc.add_text(self.field_path, &relative_path);
227 doc.add_text(self.field_file_name, &file_name);
228 doc.add_text(self.field_title, &title);
229 doc.add_text(self.field_content, &content);
230
231 let mut writer = self.writer.lock().unwrap();
233 let term = Term::from_field_text(self.field_path, &relative_path);
234 writer.delete_term(term);
235 writer.add_document(doc)?;
236 writer.commit()?;
237 drop(writer);
238
239 self.reader.reload()?;
241
242 println!("Updated index: {}", relative_path);
243 Ok(())
244 }
245
246 pub fn delete_file(&self, path: &Path) -> tantivy::Result<()> {
247 let relative_path = path
249 .strip_prefix(&self.start_dir)
250 .unwrap_or(path)
251 .to_string_lossy()
252 .to_string();
253
254 let mut writer = self.writer.lock().unwrap();
255 let term = Term::from_field_text(self.field_path, &relative_path);
256 writer.delete_term(term);
257 writer.commit()?;
258 drop(writer);
259
260 self.reader.reload()?;
262
263 println!("Removed from index: {}", relative_path);
264 Ok(())
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::fs;
272 use tempfile::TempDir;
273
274 fn create_test_file(dir: &Path, name: &str, content: &str) -> std::io::Result<()> {
275 let file_path = dir.join(name);
276 fs::write(file_path, content)
277 }
278
279 #[test]
280 fn test_search_index_creation() {
281 let temp_dir = TempDir::new().unwrap();
282 let dir_path = temp_dir.path();
283
284 create_test_file(dir_path, "test1.md", "# Test Title\nThis is test content.").unwrap();
286 create_test_file(dir_path, "test2.md", "# Another Title\nMore content here.").unwrap();
287
288 let index = SearchIndex::new(dir_path).unwrap();
289
290 assert!(index.reader.searcher().num_docs() >= 2);
292 }
293
294 #[test]
295 fn test_search_basic() {
296 let temp_dir = TempDir::new().unwrap();
297 let dir_path = temp_dir.path();
298
299 create_test_file(
300 dir_path,
301 "rust.md",
302 "# Rust Programming\nRust is a systems programming language.",
303 )
304 .unwrap();
305 create_test_file(
306 dir_path,
307 "python.md",
308 "# Python Guide\nPython is easy to learn.",
309 )
310 .unwrap();
311
312 let index = SearchIndex::new(dir_path).unwrap();
313
314 let results = index.search("Rust", 10).unwrap();
315 assert_eq!(results.len(), 1);
316 assert!(results[0].title.contains("Rust"));
317 }
318
319 #[test]
320 fn test_search_chinese() {
321 let temp_dir = TempDir::new().unwrap();
322 let dir_path = temp_dir.path();
323
324 create_test_file(
325 dir_path,
326 "chinese.md",
327 "# 中文测试\n这是一个中文搜索测试文档。",
328 )
329 .unwrap();
330 create_test_file(
331 dir_path,
332 "english.md",
333 "# English Test\nThis is an English document.",
334 )
335 .unwrap();
336
337 let index = SearchIndex::new(dir_path).unwrap();
338
339 let results = index.search("中文", 10).unwrap();
341 assert_eq!(results.len(), 1);
342 assert!(results[0].title.contains("中文"));
343
344 let results = index.search("测试", 10).unwrap();
346 assert_eq!(results.len(), 1);
347 }
348
349 #[test]
350 fn test_search_multi_field() {
351 let temp_dir = TempDir::new().unwrap();
352 let dir_path = temp_dir.path();
353
354 create_test_file(
355 dir_path,
356 "hello.md",
357 "# Hello World\nContent about greetings.",
358 )
359 .unwrap();
360 create_test_file(dir_path, "world.md", "# Universe\nThe world is vast.").unwrap();
361 create_test_file(dir_path, "test.md", "# Testing\nAnother document.").unwrap();
362
363 let index = SearchIndex::new(dir_path).unwrap();
364
365 let results = index.search("greetings", 10).unwrap();
367 assert_eq!(results.len(), 1);
368
369 let results = index.search("Hello", 10).unwrap();
371 assert!(!results.is_empty());
372 }
373
374 #[test]
375 fn test_update_file() {
376 let temp_dir = TempDir::new().unwrap();
377 let dir_path = temp_dir.path();
378 let file_path = dir_path.join("update.md");
379
380 create_test_file(dir_path, "update.md", "# Original Title\nOriginal content.").unwrap();
382
383 let index = SearchIndex::new(dir_path).unwrap();
384
385 let results = index.search("Original", 10).unwrap();
387 assert_eq!(results.len(), 1);
388
389 fs::write(&file_path, "# Updated Title\nUpdated content.").unwrap();
391 index.update_file(&file_path).unwrap();
392
393 let results = index.search("Updated", 10).unwrap();
395 assert_eq!(results.len(), 1);
396
397 let results = index.search("Original", 10).unwrap();
399 assert_eq!(results.len(), 0);
400 }
401
402 #[test]
403 fn test_delete_file() {
404 let temp_dir = TempDir::new().unwrap();
405 let dir_path = temp_dir.path();
406 let file_path = dir_path.join("delete.md");
407
408 create_test_file(dir_path, "delete.md", "# Delete Me\nThis will be deleted.").unwrap();
409
410 let index = SearchIndex::new(dir_path).unwrap();
411
412 let results = index.search("Delete", 10).unwrap();
414 assert_eq!(results.len(), 1);
415
416 index.delete_file(&file_path).unwrap();
418
419 let results = index.search("Delete", 10).unwrap();
421 assert_eq!(results.len(), 0);
422 }
423
424 #[test]
425 fn test_search_snippet_generation() {
426 let temp_dir = TempDir::new().unwrap();
427 let dir_path = temp_dir.path();
428
429 create_test_file(
430 dir_path,
431 "snippet.md",
432 "# Snippet Test\nThis document contains important information about snippets. Snippets are useful."
433 ).unwrap();
434
435 let index = SearchIndex::new(dir_path).unwrap();
436
437 let results = index.search("snippets", 10).unwrap();
438 assert_eq!(results.len(), 1);
439 assert!(!results[0].snippet.is_empty());
440 assert!(results[0].snippet.contains("<b>") || results[0].snippet.contains("snippet"));
442 }
443
444 #[test]
445 fn test_ignore_non_markdown_files() {
446 let temp_dir = TempDir::new().unwrap();
447 let dir_path = temp_dir.path();
448
449 create_test_file(dir_path, "test.md", "# Markdown\nThis is markdown.").unwrap();
450 create_test_file(dir_path, "test.txt", "# Not Markdown\nThis is text.").unwrap();
451
452 let index = SearchIndex::new(dir_path).unwrap();
453
454 let results = index.search("Markdown", 10).unwrap();
456 assert_eq!(results.len(), 1);
457
458 let results = index.search("text", 10).unwrap();
460 assert_eq!(results.len(), 0);
461 }
462
463 #[test]
464 fn test_empty_query() {
465 let temp_dir = TempDir::new().unwrap();
466 let dir_path = temp_dir.path();
467
468 create_test_file(dir_path, "test.md", "# Test\nContent").unwrap();
469
470 let index = SearchIndex::new(dir_path).unwrap();
471
472 let results = index.search("", 10);
474 assert!(results.is_err() || results.unwrap().is_empty());
475 }
476
477 #[test]
478 fn test_title_extraction() {
479 let temp_dir = TempDir::new().unwrap();
480 let dir_path = temp_dir.path();
481
482 create_test_file(dir_path, "with-title.md", "# Main Title\nContent").unwrap();
484
485 create_test_file(dir_path, "no-title.md", "Just content without heading").unwrap();
487
488 let index = SearchIndex::new(dir_path).unwrap();
489
490 let results = index.search("Main", 10).unwrap();
491 assert_eq!(results.len(), 1);
492 assert_eq!(results[0].title, "Main Title");
493
494 let results = index.search("no-title", 10).unwrap();
495 assert_eq!(results.len(), 1);
496 assert_eq!(results[0].title, "no-title");
497 }
498
499 #[test]
500 fn test_search_limit() {
501 let temp_dir = TempDir::new().unwrap();
502 let dir_path = temp_dir.path();
503
504 for i in 0..10 {
506 create_test_file(
507 dir_path,
508 &format!("file{}.md", i),
509 "# Document\nCommon content",
510 )
511 .unwrap();
512 }
513
514 let index = SearchIndex::new(dir_path).unwrap();
515
516 let results = index.search("Common", 5).unwrap();
518 assert_eq!(results.len(), 5);
519
520 let results = index.search("Common", 20).unwrap();
521 assert_eq!(results.len(), 10);
522 }
523
524 #[test]
525 fn test_subdirectory_relative_paths() {
526 let temp_dir = TempDir::new().unwrap();
527 let sub = temp_dir.path().join("notes").join("deep");
528 fs::create_dir_all(&sub).unwrap();
529 create_test_file(&sub, "nested.md", "# Nested\nDeep content here").unwrap();
530
531 let index = SearchIndex::new(temp_dir.path()).unwrap();
532 let results = index.search("Deep", 10).unwrap();
533 assert_eq!(results.len(), 1);
534 let path = &results[0].file_path;
536 assert!(
537 path.starts_with("notes"),
538 "expected relative path, got: {path}"
539 );
540 assert!(
541 path.contains("nested"),
542 "expected file name in path, got: {path}"
543 );
544 assert!(
545 !path.starts_with('/'),
546 "path should be relative, got: {path}"
547 );
548 }
549
550 #[test]
551 fn test_update_file_ignores_non_markdown() {
552 let temp_dir = TempDir::new().unwrap();
553 create_test_file(temp_dir.path(), "test.md", "# Original\nMarkdown").unwrap();
554 let index = SearchIndex::new(temp_dir.path()).unwrap();
555
556 let txt_path = temp_dir.path().join("notes.txt");
558 fs::write(&txt_path, "Some text content").unwrap();
559 index.update_file(&txt_path).unwrap();
561
562 let results = index.search("Some text content", 10).unwrap();
564 assert!(results.is_empty());
565 }
566
567 #[test]
568 fn test_update_file_no_extension() {
569 let temp_dir = TempDir::new().unwrap();
570 create_test_file(temp_dir.path(), "test.md", "# Doc\nContent").unwrap();
571 let index = SearchIndex::new(temp_dir.path()).unwrap();
572
573 let no_ext = temp_dir.path().join("README");
574 fs::write(&no_ext, "Plain text").unwrap();
575 index.update_file(&no_ext).unwrap();
576
577 let results = index.search("Plain text", 10).unwrap();
578 assert!(results.is_empty());
579 }
580}