use std::path::Path;
use tantivy::{
DateTime as TantivyDateTime, Index, IndexWriter, TantivyDocument, directory::MmapDirectory,
};
use tracing::{debug, info};
use typstify_core::{Page, utils::strip_html};
use crate::{
SearchError,
schema::{SearchFields, create_search_schema, register_tokenizers},
};
#[derive(Debug, Clone)]
pub struct IndexerConfig {
pub memory_budget: usize,
pub default_lang: String,
}
impl Default for IndexerConfig {
fn default() -> Self {
Self {
memory_budget: 50_000_000, default_lang: "en".to_string(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IndexStats {
pub document_count: usize,
pub segment_count: usize,
pub size_bytes: u64,
}
#[derive(Debug)]
pub struct SearchIndexer {
config: IndexerConfig,
index: Index,
fields: SearchFields,
}
impl SearchIndexer {
pub fn new(index_path: &Path, config: IndexerConfig) -> Result<Self, SearchError> {
std::fs::create_dir_all(index_path).map_err(|e| SearchError::Io(e.to_string()))?;
let (schema, fields) = create_search_schema();
let directory =
MmapDirectory::open(index_path).map_err(|e| SearchError::Index(e.to_string()))?;
let index = Index::open_or_create(directory, schema)
.map_err(|e| SearchError::Index(e.to_string()))?;
register_tokenizers(&index);
Ok(Self {
config,
index,
fields,
})
}
pub fn new_in_memory(config: IndexerConfig) -> Result<Self, SearchError> {
let (schema, fields) = create_search_schema();
let index = Index::create_in_ram(schema);
register_tokenizers(&index);
Ok(Self {
config,
index,
fields,
})
}
pub fn index_pages(&self, pages: &[&Page]) -> Result<usize, SearchError> {
let mut writer = self
.index
.writer(self.config.memory_budget)
.map_err(|e| SearchError::Index(e.to_string()))?;
let mut count = 0;
for page in pages {
self.index_page(&mut writer, page)?;
count += 1;
}
writer
.commit()
.map_err(|e| SearchError::Index(e.to_string()))?;
info!(count, "Indexed pages");
Ok(count)
}
fn index_page(&self, writer: &mut IndexWriter, page: &Page) -> Result<(), SearchError> {
let mut doc = TantivyDocument::new();
doc.add_text(self.fields.title, &page.title);
let body_text = strip_html(&page.content);
doc.add_text(self.fields.body, &body_text);
doc.add_text(self.fields.url, &page.url);
doc.add_text(self.fields.lang, &page.lang);
let tags_text = page.tags.join(" ");
doc.add_text(self.fields.tags, &tags_text);
if let Some(date) = page.date {
let tantivy_date = TantivyDateTime::from_timestamp_secs(date.timestamp());
doc.add_date(self.fields.date, tantivy_date);
}
writer
.add_document(doc)
.map_err(|e| SearchError::Index(e.to_string()))?;
debug!(url = %page.url, title = %page.title, "Indexed page");
Ok(())
}
pub fn optimize(&self) -> Result<(), SearchError> {
let writer: IndexWriter<TantivyDocument> = self
.index
.writer(self.config.memory_budget)
.map_err(|e| SearchError::Index(e.to_string()))?;
writer
.wait_merging_threads()
.map_err(|e| SearchError::Index(e.to_string()))?;
info!("Index optimization complete");
Ok(())
}
pub fn stats(&self) -> Result<IndexStats, SearchError> {
let reader = self
.index
.reader()
.map_err(|e| SearchError::Index(e.to_string()))?;
let searcher = reader.searcher();
let segment_count = searcher.segment_readers().len();
let document_count = searcher.num_docs() as usize;
let size_bytes = searcher
.segment_readers()
.iter()
.map(|r| r.num_docs() as u64 * 500) .sum();
Ok(IndexStats {
document_count,
segment_count,
size_bytes,
})
}
pub fn index(&self) -> &Index {
&self.index
}
pub fn fields(&self) -> &SearchFields {
&self.fields
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
fn create_test_page(url: &str, title: &str, content: &str) -> Page {
Page {
url: url.to_string(),
title: title.to_string(),
description: None,
date: Some(Utc::now()),
updated: None,
draft: false,
lang: "en".to_string(),
is_default_lang: true,
canonical_id: url.trim_start_matches('/').to_string(),
tags: vec!["rust".to_string(), "search".to_string()],
categories: vec![],
content: content.to_string(),
summary: None,
reading_time: Some(5),
word_count: Some(500),
source_path: None,
aliases: vec![],
toc: vec![],
custom_js: vec![],
custom_css: vec![],
template: None,
weight: 0,
}
}
#[test]
fn test_index_single_page() {
let indexer = SearchIndexer::new_in_memory(IndexerConfig::default()).unwrap();
let page = create_test_page("/test", "Test Page", "<p>Test content</p>");
let count = indexer.index_pages(&[&page]).unwrap();
assert_eq!(count, 1);
let stats = indexer.stats().unwrap();
assert_eq!(stats.document_count, 1);
}
#[test]
fn test_index_multiple_pages() {
let indexer = SearchIndexer::new_in_memory(IndexerConfig::default()).unwrap();
let page1 = create_test_page("/page1", "Page One", "<p>First page</p>");
let page2 = create_test_page("/page2", "Page Two", "<p>Second page</p>");
let page3 = create_test_page("/page3", "Page Three", "<p>Third page</p>");
let count = indexer.index_pages(&[&page1, &page2, &page3]).unwrap();
assert_eq!(count, 3);
let stats = indexer.stats().unwrap();
assert_eq!(stats.document_count, 3);
}
#[test]
fn test_indexer_config_default() {
let config = IndexerConfig::default();
assert_eq!(config.memory_budget, 50_000_000);
assert_eq!(config.default_lang, "en");
}
}