use std::io;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::schema::{
Field, IndexRecordOption, NumericOptions, Schema, TEXT, TextFieldIndexing, TextOptions, Value,
};
use tantivy::{Index, IndexWriter, ReloadPolicy, TantivyDocument};
use crate::entry::SessionEntry;
use crate::meta::SessionMeta;
use crate::search::{self, SessionHit, SessionSearchOptions, snippet_from_text};
const INDEXER_HEAP_BYTES: usize = 15_000_000;
const INDEX_SUBDIR: &str = ".search_index";
#[derive(Clone, Copy)]
struct Fields {
session_id: Field,
session_title: Field,
entry_type: Field,
timestamp: Field,
body: Field,
entry_json: Field,
}
impl Fields {
fn register(builder: &mut tantivy::schema::SchemaBuilder) -> Self {
let raw = TextOptions::default()
.set_indexing_options(
TextFieldIndexing::default()
.set_tokenizer("raw")
.set_index_option(IndexRecordOption::Basic),
)
.set_stored();
let stored_only = TextOptions::default().set_stored();
Self {
session_id: builder.add_text_field("session_id", raw.clone()),
session_title: builder.add_text_field("session_title", stored_only.clone()),
entry_type: builder.add_text_field("entry_type", raw),
timestamp: builder.add_u64_field(
"timestamp",
NumericOptions::default().set_indexed().set_stored(),
),
body: builder.add_text_field("body", TEXT),
entry_json: builder.add_text_field("entry_json", stored_only),
}
}
}
#[derive(Clone)]
pub struct TantivyIndex {
inner: Arc<Mutex<Inner>>,
}
struct Inner {
index: Index,
fields: Fields,
writer: Option<IndexWriter>,
#[cfg(test)]
full_reindex_count: usize,
}
impl Inner {
fn writer(&mut self) -> io::Result<&mut IndexWriter> {
if self.writer.is_none() {
self.writer = Some(
self.index
.writer(INDEXER_HEAP_BYTES)
.map_err(tantivy_to_io)?,
);
}
Ok(self.writer.as_mut().expect("just created"))
}
fn commit(&mut self) -> io::Result<()> {
let writer = self.writer()?;
if let Err(err) = writer.commit() {
self.writer = None;
return Err(tantivy_to_io(err));
}
Ok(())
}
}
impl TantivyIndex {
pub fn open_or_create(sessions_dir: &Path) -> io::Result<Self> {
let index_dir = sessions_dir.join(INDEX_SUBDIR);
std::fs::create_dir_all(&index_dir)?;
let (index, fields) = open_or_create_index(&index_dir)?;
Ok(Self {
inner: Arc::new(Mutex::new(Inner {
index,
fields,
writer: None,
#[cfg(test)]
full_reindex_count: 0,
})),
})
}
pub fn index_session(&self, meta: &SessionMeta, entries: &[SessionEntry]) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
#[cfg(test)]
{
inner.full_reindex_count += 1;
}
let fields = inner.fields;
let writer = inner.writer()?;
delete_session_docs(writer, &fields, &meta.id);
add_entry_docs(writer, &fields, meta, entries)?;
inner.commit()
}
pub fn append_session_entries(
&self,
meta: &SessionMeta,
entries: &[SessionEntry],
) -> io::Result<()> {
if entries.is_empty() {
return Ok(());
}
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let fields = inner.fields;
let writer = inner.writer()?;
add_entry_docs(writer, &fields, meta, entries)?;
inner.commit()
}
pub fn clear_all(&self) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner
.writer()?
.delete_all_documents()
.map_err(tantivy_to_io)?;
inner.commit()
}
pub fn delete_session(&self, session_id: &str) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let fields = inner.fields;
let writer = inner.writer()?;
delete_session_docs(writer, &fields, session_id);
inner.commit()
}
#[cfg(test)]
pub(crate) fn full_reindex_count(&self) -> usize {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.full_reindex_count
}
pub fn search(
&self,
query: &str,
options: &SessionSearchOptions,
) -> io::Result<Vec<SessionHit>> {
let limit = options.limit();
if query.trim().is_empty() || limit == 0 {
return Ok(Vec::new());
}
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let reader = inner
.index
.reader_builder()
.reload_policy(ReloadPolicy::Manual)
.try_into()
.map_err(tantivy_to_io)?;
reader.reload().map_err(tantivy_to_io)?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&inner.index, vec![inner.fields.body]);
let sanitised = sanitise_query(query);
let parsed_query = query_parser
.parse_query(&sanitised)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
let fetch = (limit * 4).max(200);
let top_docs: Vec<(tantivy::Score, tantivy::DocAddress)> = searcher
.search(&parsed_query, &TopDocs::with_limit(fetch).order_by_score())
.map_err(tantivy_to_io)?;
let mut hits: Vec<SessionHit> = Vec::new();
for (score, doc_address) in top_docs {
if hits.len() >= limit {
break;
}
let doc: TantivyDocument = searcher.doc(doc_address).map_err(tantivy_to_io)?;
let session_id = get_text_field(&doc, inner.fields.session_id);
let session_title = get_text_field(&doc, inner.fields.session_title);
let entry_json = get_text_field(&doc, inner.fields.entry_json);
let entry_type_name = get_text_field(&doc, inner.fields.entry_type);
let timestamp_val: u64 = doc
.get_first(inner.fields.timestamp)
.and_then(|v| v.as_u64())
.unwrap_or(0);
if options
.session_ids
.as_ref()
.is_some_and(|ids| !ids.contains(&session_id))
{
continue;
}
if options
.entry_types
.as_ref()
.is_some_and(|types| !types.contains(&entry_type_name))
{
continue;
}
if options
.start_time
.is_some_and(|start| timestamp_val < start.timestamp().cast_unsigned())
{
continue;
}
if options
.end_time
.is_some_and(|end| timestamp_val > end.timestamp().cast_unsigned())
{
continue;
}
let entry: SessionEntry = match serde_json::from_str(&entry_json) {
Ok(e) => e,
Err(err) => {
tracing::warn!(
session_id = %session_id,
error = %err,
"skipping tantivy hit with undeserializable entry_json"
);
continue;
}
};
let body = search::searchable_text_pub(&entry);
let snippet = snippet_from_text(&body, 0);
#[allow(clippy::cast_sign_loss)]
let score = (score.max(0.0) * 1000.0) as usize;
hits.push(SessionHit::new(
session_id,
session_title,
entry,
score,
snippet,
));
}
Ok(hits)
}
}
fn open_or_create_index(index_dir: &Path) -> io::Result<(Index, Fields)> {
let mut schema_builder = Schema::builder();
let fields = Fields::register(&mut schema_builder);
let schema = schema_builder.build();
let dir = tantivy::directory::MmapDirectory::open(index_dir).map_err(tantivy_to_io)?;
let index = Index::open_or_create(dir, schema).map_err(tantivy_to_io)?;
Ok((index, fields))
}
fn delete_session_docs(writer: &IndexWriter, fields: &Fields, session_id: &str) {
let term = tantivy::Term::from_field_text(fields.session_id, session_id);
writer.delete_term(term);
}
fn add_entry_docs(
writer: &IndexWriter,
fields: &Fields,
meta: &SessionMeta,
entries: &[SessionEntry],
) -> io::Result<()> {
for entry in entries {
let body = search::searchable_text_pub(entry);
if body.is_empty() {
continue;
}
let entry_json = serde_json::to_string(entry).map_err(io::Error::other)?;
let timestamp = entry.timestamp().unwrap_or(0);
let doc = tantivy::doc!(
fields.session_id => meta.id.as_str(),
fields.session_title => meta.title.as_str(),
fields.entry_type => entry.entry_type_name(),
fields.timestamp => timestamp,
fields.body => body.as_str(),
fields.entry_json => entry_json.as_str()
);
writer.add_document(doc).map_err(tantivy_to_io)?;
}
Ok(())
}
fn get_text_field(doc: &TantivyDocument, field: Field) -> String {
doc.get_first(field)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
fn sanitise_query(query: &str) -> String {
query
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
' '
}
})
.collect()
}
fn tantivy_to_io(e: impl std::fmt::Display) -> io::Error {
io::Error::other(e.to_string())
}