Skip to main content

markon_core/
search.rs

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/// Query string for `GET /search?q=…`.
15#[derive(Deserialize)]
16pub struct SearchQuery {
17    pub q: String,
18}
19
20/// One hit returned by `GET /search`.
21#[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        // Build schema
45        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        // Use STRING for path field - indexed but not tokenized, so we can delete by exact match
56        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        // Create index in RAM
64        let index = Index::create_in_ram(schema.clone());
65
66        // Register jieba tokenizer
67        index.tokenizers().register("jieba", JiebaTokenizer {});
68
69        // Create writer and reader
70        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        // Index all markdown files
86        search_index.index_directory(start_dir)?;
87
88        Ok(search_index)
89    }
90
91    /// Acquire the writer lock, mapping poisoning to a tantivy error
92    /// instead of panicking. All writer access in this module goes
93    /// through this helper so that a panic in one indexing path cannot
94    /// take down later writes.
95    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        // Stage 1: parallel CPU-bound work. Each worker reads its file
114        // and builds the TantivyDocument without ever touching the
115        // writer lock, so rayon's parallelism is no longer serialised
116        // on a single mutex. Unreadable files are silently skipped, as
117        // before.
118        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        // Stage 2: serial write phase. Acquire the writer lock exactly
127        // once, batch every add_document, then commit. The guard is
128        // dropped at the end of the block, before reload(), so
129        // concurrent readers are not blocked any longer than needed.
130        {
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    /// Build a TantivyDocument for `path` with `content`. Pure CPU
145    /// work — does not touch the writer. Safe to call from rayon
146    /// workers in parallel.
147    fn build_document(&self, path: &Path, content: &str) -> TantivyDocument {
148        // Calculate relative path from start_dir
149        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        // Extract title from first heading or filename
162        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        // Search across file_name, title, and content
180        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        // Calculate relative path from start_dir
233        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        // Delete and re-add in same transaction
258        {
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        // Reload reader to see the changes
267        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        // Calculate relative path from start_dir
275        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        // Reload reader to see the changes
289        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 markdown files
313        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        // Verify index was created
319        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        // Test Chinese search
368        let results = index.search("中文", 10).unwrap();
369        assert_eq!(results.len(), 1);
370        assert!(results[0].title.contains("中文"));
371
372        // Test mixed search
373        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        // Search should find "greetings" in content only
394        let results = index.search("greetings", 10).unwrap();
395        assert_eq!(results.len(), 1);
396
397        // Search for "Hello" should find in title
398        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 initial file
409        create_test_file(dir_path, "update.md", "# Original Title\nOriginal content.").unwrap();
410
411        let index = SearchIndex::new(dir_path).unwrap();
412
413        // Verify original content
414        let results = index.search("Original", 10).unwrap();
415        assert_eq!(results.len(), 1);
416
417        // Update file
418        fs::write(&file_path, "# Updated Title\nUpdated content.").unwrap();
419        index.update_file(&file_path).unwrap();
420
421        // Search for new content
422        let results = index.search("Updated", 10).unwrap();
423        assert_eq!(results.len(), 1);
424
425        // Old content should not be found
426        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        // Verify file is indexed
441        let results = index.search("Delete", 10).unwrap();
442        assert_eq!(results.len(), 1);
443
444        // Delete file
445        index.delete_file(&file_path).unwrap();
446
447        // Verify file is removed from index
448        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        // Snippet should contain highlighted text (HTML tags)
469        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        // Should find markdown file
483        let results = index.search("Markdown", 10).unwrap();
484        assert_eq!(results.len(), 1);
485
486        // Should not find text file
487        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        // Empty query should return error or empty results
501        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        // File with heading
511        create_test_file(dir_path, "with-title.md", "# Main Title\nContent").unwrap();
512
513        // File without heading (should use filename)
514        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        // Create multiple files with common content
533        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        // Test limit works
545        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        // Path should be relative, using forward slashes
563        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        // Write a .txt file and try to update — should be no-op
585        let txt_path = temp_dir.path().join("notes.txt");
586        fs::write(&txt_path, "Some text content").unwrap();
587        // Should not error
588        index.update_file(&txt_path).unwrap();
589
590        // Searching for the txt content should yield nothing
591        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    /// Stress the parallel parse -> serial write pipeline with enough
610    /// files that rayon will fan out across multiple workers. Verifies
611    /// every doc lands in the index and that content from arbitrary
612    /// files is searchable.
613    #[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            // Vary body size so workers spend different amounts of
621            // CPU per file. Each file embeds its own unique marker
622            // token so we can probe individual docs after indexing.
623            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        // Every file must be present after the parallel pipeline.
633        assert_eq!(
634            index.reader.searcher().num_docs(),
635            N as u64,
636            "parallel indexing dropped documents"
637        );
638
639        // Probe a handful of unique markers to prove the content of
640        // individual docs survived the parse -> write split, not just
641        // the document count.
642        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        // Title-based search still works across the parallel pipeline.
654        let results = index.search("Doc", 100).unwrap();
655        assert_eq!(results.len(), N);
656    }
657
658    /// Poison the writer mutex from a panicking thread and verify
659    /// that subsequent public write calls surface a TantivyError
660    /// instead of panicking. This proves the writer() helper turned
661    /// the historical .lock().unwrap() panic path into a recoverable
662    /// error.
663    #[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        // Poison the writer mutex: grab the lock on another thread
671        // and panic while holding it. std::sync::Mutex marks the
672        // mutex poisoned when the panicking thread unwinds.
673        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        // We expect the thread to panic. join() returns Err in that
679        // case; the panic must not propagate into this test thread.
680        let join_result = poisoner.join();
681        assert!(
682            join_result.is_err(),
683            "poisoner thread was supposed to panic"
684        );
685
686        // The lock is now poisoned. Any subsequent public write path
687        // (update_file / delete_file) must return an error, not panic.
688        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}