Skip to main content

markon_core/
search.rs

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        // Build schema
43        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        // Use STRING for path field - indexed but not tokenized, so we can delete by exact match
54        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        // Create index in RAM
62        let index = Index::create_in_ram(schema.clone());
63
64        // Register jieba tokenizer
65        index.tokenizers().register("jieba", JiebaTokenizer {});
66
67        // Create writer and reader
68        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        // Index all markdown files
84        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        // Reload reader to see the committed changes
108        self.reader.reload()?;
109        println!("Indexing complete!");
110
111        Ok(())
112    }
113
114    fn index_file(&self, path: &Path, content: &str) -> tantivy::Result<()> {
115        // Calculate relative path from start_dir
116        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        // Extract title from first heading or filename
129        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        // Search across file_name, title, and content
151        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        // Calculate relative path from start_dir
204        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        // Delete and re-add in same transaction
229        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        // Reload reader to see the changes
237        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        // Calculate relative path from start_dir
245        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        // Reload reader to see the changes
258        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 markdown files
282        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        // Verify index was created
288        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        // Test Chinese search
337        let results = index.search("中文", 10).unwrap();
338        assert_eq!(results.len(), 1);
339        assert!(results[0].title.contains("中文"));
340
341        // Test mixed search
342        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        // Search should find "greetings" in content only
363        let results = index.search("greetings", 10).unwrap();
364        assert_eq!(results.len(), 1);
365
366        // Search for "Hello" should find in title
367        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 initial file
378        create_test_file(dir_path, "update.md", "# Original Title\nOriginal content.").unwrap();
379
380        let index = SearchIndex::new(dir_path).unwrap();
381
382        // Verify original content
383        let results = index.search("Original", 10).unwrap();
384        assert_eq!(results.len(), 1);
385
386        // Update file
387        fs::write(&file_path, "# Updated Title\nUpdated content.").unwrap();
388        index.update_file(&file_path).unwrap();
389
390        // Search for new content
391        let results = index.search("Updated", 10).unwrap();
392        assert_eq!(results.len(), 1);
393
394        // Old content should not be found
395        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        // Verify file is indexed
410        let results = index.search("Delete", 10).unwrap();
411        assert_eq!(results.len(), 1);
412
413        // Delete file
414        index.delete_file(&file_path).unwrap();
415
416        // Verify file is removed from index
417        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        // Snippet should contain highlighted text (HTML tags)
438        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        // Should find markdown file
452        let results = index.search("Markdown", 10).unwrap();
453        assert_eq!(results.len(), 1);
454
455        // Should not find text file
456        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        // Empty query should return error or empty results
470        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        // File with heading
480        create_test_file(dir_path, "with-title.md", "# Main Title\nContent").unwrap();
481
482        // File without heading (should use filename)
483        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        // Create multiple files with common content
502        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        // Test limit works
514        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        // Path should be relative, using forward slashes
532        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        // Write a .txt file and try to update — should be no-op
554        let txt_path = temp_dir.path().join("notes.txt");
555        fs::write(&txt_path, "Some text content").unwrap();
556        // Should not error
557        index.update_file(&txt_path).unwrap();
558
559        // Searching for the txt content should yield nothing
560        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}