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 println!("Indexing markdown files in {:?}...", dir);
91
92 for entry in WalkDir::new(dir)
93 .into_iter()
94 .filter_map(Result::ok)
95 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
96 {
97 let path = entry.path();
98 if let Ok(content) = fs::read_to_string(path) {
99 self.index_file(path, &content)?;
100 }
101 }
102
103 let mut writer = self.writer.lock().unwrap();
104 writer.commit()?;
105 drop(writer);
106
107 self.reader.reload()?;
109 println!("Indexing complete!");
110
111 Ok(())
112 }
113
114 fn index_file(&self, path: &Path, content: &str) -> tantivy::Result<()> {
115 let relative_path = path
117 .strip_prefix(&self.start_dir)
118 .unwrap_or(path)
119 .to_string_lossy()
120 .to_string();
121
122 let file_name = path
123 .file_stem()
124 .and_then(|s| s.to_str())
125 .unwrap_or("")
126 .to_string();
127
128 let title = content
130 .lines()
131 .find(|line| line.starts_with('#'))
132 .map(|line| line.trim_start_matches('#').trim().to_string())
133 .unwrap_or_else(|| file_name.clone());
134
135 let mut doc = TantivyDocument::default();
136 doc.add_text(self.field_path, &relative_path);
137 doc.add_text(self.field_file_name, &file_name);
138 doc.add_text(self.field_title, &title);
139 doc.add_text(self.field_content, content);
140
141 let writer = self.writer.lock().unwrap();
142 writer.add_document(doc)?;
143
144 Ok(())
145 }
146
147 pub fn search(&self, query_str: &str, limit: usize) -> tantivy::Result<Vec<SearchResult>> {
148 let searcher = self.reader.searcher();
149
150 let query_parser = QueryParser::for_index(
152 &self.index,
153 vec![self.field_file_name, self.field_title, self.field_content],
154 );
155
156 let query = query_parser.parse_query(query_str)?;
157 let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
158
159 let mut results = Vec::new();
160 let snippet_generator = SnippetGenerator::create(&searcher, &query, self.field_content)?;
161
162 for (_score, doc_address) in top_docs {
163 let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
164
165 let file_path = retrieved_doc
166 .get_first(self.field_path)
167 .and_then(|v| v.as_str())
168 .unwrap_or("")
169 .to_string();
170
171 let file_name = retrieved_doc
172 .get_first(self.field_file_name)
173 .and_then(|v| v.as_str())
174 .unwrap_or("")
175 .to_string();
176
177 let title = retrieved_doc
178 .get_first(self.field_title)
179 .and_then(|v| v.as_str())
180 .unwrap_or("")
181 .to_string();
182
183 let snippet = snippet_generator.snippet_from_doc(&retrieved_doc);
184 let snippet_html = snippet.to_html();
185
186 results.push(SearchResult {
187 file_path,
188 file_name,
189 title,
190 snippet: snippet_html,
191 });
192 }
193
194 Ok(results)
195 }
196
197 pub fn update_file(&self, path: &Path) -> tantivy::Result<()> {
198 if path.extension().is_none_or(|ext| ext != "md") {
199 return Ok(());
200 }
201
202 let content = fs::read_to_string(path)?;
203 let relative_path = path
205 .strip_prefix(&self.start_dir)
206 .unwrap_or(path)
207 .to_string_lossy()
208 .to_string();
209
210 let file_name = path
211 .file_stem()
212 .and_then(|s| s.to_str())
213 .unwrap_or("")
214 .to_string();
215
216 let title = content
217 .lines()
218 .find(|line| line.starts_with('#'))
219 .map(|line| line.trim_start_matches('#').trim().to_string())
220 .unwrap_or_else(|| file_name.clone());
221
222 let mut doc = TantivyDocument::default();
223 doc.add_text(self.field_path, &relative_path);
224 doc.add_text(self.field_file_name, &file_name);
225 doc.add_text(self.field_title, &title);
226 doc.add_text(self.field_content, &content);
227
228 let mut writer = self.writer.lock().unwrap();
230 let term = Term::from_field_text(self.field_path, &relative_path);
231 writer.delete_term(term);
232 writer.add_document(doc)?;
233 writer.commit()?;
234 drop(writer);
235
236 self.reader.reload()?;
238
239 println!("Updated index: {}", relative_path);
240 Ok(())
241 }
242
243 pub fn delete_file(&self, path: &Path) -> tantivy::Result<()> {
244 let relative_path = path
246 .strip_prefix(&self.start_dir)
247 .unwrap_or(path)
248 .to_string_lossy()
249 .to_string();
250
251 let mut writer = self.writer.lock().unwrap();
252 let term = Term::from_field_text(self.field_path, &relative_path);
253 writer.delete_term(term);
254 writer.commit()?;
255 drop(writer);
256
257 self.reader.reload()?;
259
260 println!("Removed from index: {}", relative_path);
261 Ok(())
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::fs;
269 use tempfile::TempDir;
270
271 fn create_test_file(dir: &Path, name: &str, content: &str) -> std::io::Result<()> {
272 let file_path = dir.join(name);
273 fs::write(file_path, content)
274 }
275
276 #[test]
277 fn test_search_index_creation() {
278 let temp_dir = TempDir::new().unwrap();
279 let dir_path = temp_dir.path();
280
281 create_test_file(dir_path, "test1.md", "# Test Title\nThis is test content.").unwrap();
283 create_test_file(dir_path, "test2.md", "# Another Title\nMore content here.").unwrap();
284
285 let index = SearchIndex::new(dir_path).unwrap();
286
287 assert!(index.reader.searcher().num_docs() >= 2);
289 }
290
291 #[test]
292 fn test_search_basic() {
293 let temp_dir = TempDir::new().unwrap();
294 let dir_path = temp_dir.path();
295
296 create_test_file(
297 dir_path,
298 "rust.md",
299 "# Rust Programming\nRust is a systems programming language.",
300 )
301 .unwrap();
302 create_test_file(
303 dir_path,
304 "python.md",
305 "# Python Guide\nPython is easy to learn.",
306 )
307 .unwrap();
308
309 let index = SearchIndex::new(dir_path).unwrap();
310
311 let results = index.search("Rust", 10).unwrap();
312 assert_eq!(results.len(), 1);
313 assert!(results[0].title.contains("Rust"));
314 }
315
316 #[test]
317 fn test_search_chinese() {
318 let temp_dir = TempDir::new().unwrap();
319 let dir_path = temp_dir.path();
320
321 create_test_file(
322 dir_path,
323 "chinese.md",
324 "# 中文测试\n这是一个中文搜索测试文档。",
325 )
326 .unwrap();
327 create_test_file(
328 dir_path,
329 "english.md",
330 "# English Test\nThis is an English document.",
331 )
332 .unwrap();
333
334 let index = SearchIndex::new(dir_path).unwrap();
335
336 let results = index.search("中文", 10).unwrap();
338 assert_eq!(results.len(), 1);
339 assert!(results[0].title.contains("中文"));
340
341 let results = index.search("测试", 10).unwrap();
343 assert_eq!(results.len(), 1);
344 }
345
346 #[test]
347 fn test_search_multi_field() {
348 let temp_dir = TempDir::new().unwrap();
349 let dir_path = temp_dir.path();
350
351 create_test_file(
352 dir_path,
353 "hello.md",
354 "# Hello World\nContent about greetings.",
355 )
356 .unwrap();
357 create_test_file(dir_path, "world.md", "# Universe\nThe world is vast.").unwrap();
358 create_test_file(dir_path, "test.md", "# Testing\nAnother document.").unwrap();
359
360 let index = SearchIndex::new(dir_path).unwrap();
361
362 let results = index.search("greetings", 10).unwrap();
364 assert_eq!(results.len(), 1);
365
366 let results = index.search("Hello", 10).unwrap();
368 assert!(!results.is_empty());
369 }
370
371 #[test]
372 fn test_update_file() {
373 let temp_dir = TempDir::new().unwrap();
374 let dir_path = temp_dir.path();
375 let file_path = dir_path.join("update.md");
376
377 create_test_file(dir_path, "update.md", "# Original Title\nOriginal content.").unwrap();
379
380 let index = SearchIndex::new(dir_path).unwrap();
381
382 let results = index.search("Original", 10).unwrap();
384 assert_eq!(results.len(), 1);
385
386 fs::write(&file_path, "# Updated Title\nUpdated content.").unwrap();
388 index.update_file(&file_path).unwrap();
389
390 let results = index.search("Updated", 10).unwrap();
392 assert_eq!(results.len(), 1);
393
394 let results = index.search("Original", 10).unwrap();
396 assert_eq!(results.len(), 0);
397 }
398
399 #[test]
400 fn test_delete_file() {
401 let temp_dir = TempDir::new().unwrap();
402 let dir_path = temp_dir.path();
403 let file_path = dir_path.join("delete.md");
404
405 create_test_file(dir_path, "delete.md", "# Delete Me\nThis will be deleted.").unwrap();
406
407 let index = SearchIndex::new(dir_path).unwrap();
408
409 let results = index.search("Delete", 10).unwrap();
411 assert_eq!(results.len(), 1);
412
413 index.delete_file(&file_path).unwrap();
415
416 let results = index.search("Delete", 10).unwrap();
418 assert_eq!(results.len(), 0);
419 }
420
421 #[test]
422 fn test_search_snippet_generation() {
423 let temp_dir = TempDir::new().unwrap();
424 let dir_path = temp_dir.path();
425
426 create_test_file(
427 dir_path,
428 "snippet.md",
429 "# Snippet Test\nThis document contains important information about snippets. Snippets are useful."
430 ).unwrap();
431
432 let index = SearchIndex::new(dir_path).unwrap();
433
434 let results = index.search("snippets", 10).unwrap();
435 assert_eq!(results.len(), 1);
436 assert!(!results[0].snippet.is_empty());
437 assert!(results[0].snippet.contains("<b>") || results[0].snippet.contains("snippet"));
439 }
440
441 #[test]
442 fn test_ignore_non_markdown_files() {
443 let temp_dir = TempDir::new().unwrap();
444 let dir_path = temp_dir.path();
445
446 create_test_file(dir_path, "test.md", "# Markdown\nThis is markdown.").unwrap();
447 create_test_file(dir_path, "test.txt", "# Not Markdown\nThis is text.").unwrap();
448
449 let index = SearchIndex::new(dir_path).unwrap();
450
451 let results = index.search("Markdown", 10).unwrap();
453 assert_eq!(results.len(), 1);
454
455 let results = index.search("text", 10).unwrap();
457 assert_eq!(results.len(), 0);
458 }
459
460 #[test]
461 fn test_empty_query() {
462 let temp_dir = TempDir::new().unwrap();
463 let dir_path = temp_dir.path();
464
465 create_test_file(dir_path, "test.md", "# Test\nContent").unwrap();
466
467 let index = SearchIndex::new(dir_path).unwrap();
468
469 let results = index.search("", 10);
471 assert!(results.is_err() || results.unwrap().is_empty());
472 }
473
474 #[test]
475 fn test_title_extraction() {
476 let temp_dir = TempDir::new().unwrap();
477 let dir_path = temp_dir.path();
478
479 create_test_file(dir_path, "with-title.md", "# Main Title\nContent").unwrap();
481
482 create_test_file(dir_path, "no-title.md", "Just content without heading").unwrap();
484
485 let index = SearchIndex::new(dir_path).unwrap();
486
487 let results = index.search("Main", 10).unwrap();
488 assert_eq!(results.len(), 1);
489 assert_eq!(results[0].title, "Main Title");
490
491 let results = index.search("no-title", 10).unwrap();
492 assert_eq!(results.len(), 1);
493 assert_eq!(results[0].title, "no-title");
494 }
495
496 #[test]
497 fn test_search_limit() {
498 let temp_dir = TempDir::new().unwrap();
499 let dir_path = temp_dir.path();
500
501 for i in 0..10 {
503 create_test_file(
504 dir_path,
505 &format!("file{}.md", i),
506 "# Document\nCommon content",
507 )
508 .unwrap();
509 }
510
511 let index = SearchIndex::new(dir_path).unwrap();
512
513 let results = index.search("Common", 5).unwrap();
515 assert_eq!(results.len(), 5);
516
517 let results = index.search("Common", 20).unwrap();
518 assert_eq!(results.len(), 10);
519 }
520
521 #[test]
522 fn test_subdirectory_relative_paths() {
523 let temp_dir = TempDir::new().unwrap();
524 let sub = temp_dir.path().join("notes").join("deep");
525 fs::create_dir_all(&sub).unwrap();
526 create_test_file(&sub, "nested.md", "# Nested\nDeep content here").unwrap();
527
528 let index = SearchIndex::new(temp_dir.path()).unwrap();
529 let results = index.search("Deep", 10).unwrap();
530 assert_eq!(results.len(), 1);
531 let path = &results[0].file_path;
533 assert!(
534 path.starts_with("notes"),
535 "expected relative path, got: {path}"
536 );
537 assert!(
538 path.contains("nested"),
539 "expected file name in path, got: {path}"
540 );
541 assert!(
542 !path.starts_with('/'),
543 "path should be relative, got: {path}"
544 );
545 }
546
547 #[test]
548 fn test_update_file_ignores_non_markdown() {
549 let temp_dir = TempDir::new().unwrap();
550 create_test_file(temp_dir.path(), "test.md", "# Original\nMarkdown").unwrap();
551 let index = SearchIndex::new(temp_dir.path()).unwrap();
552
553 let txt_path = temp_dir.path().join("notes.txt");
555 fs::write(&txt_path, "Some text content").unwrap();
556 index.update_file(&txt_path).unwrap();
558
559 let results = index.search("Some text content", 10).unwrap();
561 assert!(results.is_empty());
562 }
563
564 #[test]
565 fn test_update_file_no_extension() {
566 let temp_dir = TempDir::new().unwrap();
567 create_test_file(temp_dir.path(), "test.md", "# Doc\nContent").unwrap();
568 let index = SearchIndex::new(temp_dir.path()).unwrap();
569
570 let no_ext = temp_dir.path().join("README");
571 fs::write(&no_ext, "Plain text").unwrap();
572 index.update_file(&no_ext).unwrap();
573
574 let results = index.search("Plain text", 10).unwrap();
575 assert!(results.is_empty());
576 }
577}