use std::path::{Path, PathBuf};
use super::IndexError;
use super::meta::StoreMeta;
use super::snapshot::{
DiskSnapshotStore, SnapshotId, SnapshotLease, SnapshotManifest, SnapshotRead, SnapshotStore,
SnapshotWrite, SnapshotWriterSession,
};
pub struct IndexStore<S: SnapshotStore = DiskSnapshotStore> {
snapshots: S,
sift_dir: PathBuf,
meta: Option<StoreMeta>,
}
impl IndexStore<DiskSnapshotStore> {
#[must_use]
pub const fn meta(&self) -> Option<&StoreMeta> {
self.meta.as_ref()
}
pub fn refresh_meta(&mut self, meta: &StoreMeta) -> crate::Result<()> {
meta.write(&self.sift_dir)?;
self.meta = Some(meta.clone());
Ok(())
}
}
impl IndexStore<DiskSnapshotStore> {
pub fn open(sift_dir: &Path) -> crate::Result<Self> {
let snapshots = DiskSnapshotStore::open(sift_dir)?;
let meta = StoreMeta::read(sift_dir).ok();
Ok(Self {
snapshots,
sift_dir: sift_dir.to_path_buf(),
meta,
})
}
pub fn open_or_create(sift_dir: &Path, meta: &StoreMeta) -> crate::Result<Self> {
std::fs::create_dir_all(sift_dir)?;
if !StoreMeta::path(sift_dir).exists() {
let guard = acquire_write_lock(sift_dir)?;
if !StoreMeta::path(sift_dir).exists() {
meta.write(sift_dir)?;
}
drop(guard);
}
Self::open(sift_dir)
}
#[must_use]
pub fn snapshot_dir(&self, id: &str) -> PathBuf {
self.sift_dir.join("snapshots").join(id)
}
pub(crate) fn open_current(&self) -> crate::Result<super::snapshot::Snapshot> {
for attempt in 0..2 {
let Some(current_id) = DiskSnapshotStore::read_current_id(&self.sift_dir)? else {
return Ok(super::snapshot::Snapshot::empty(PathBuf::new()));
};
let Some(ref meta) = self.meta else {
return Err(crate::Error::Index(IndexError::Io {
path: self.sift_dir.join("sift.meta"),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"store metadata not found",
),
}));
};
let root = meta.corpus.root.clone();
let corpus_kind = meta.corpus.kind;
let snap_dir = self.sift_dir.join("snapshots").join(¤t_id);
let lease = SnapshotLease::create_file(&self.sift_dir, ¤t_id)?;
if !snap_dir.exists() {
drop(lease);
continue;
}
let manifest_path = snap_dir.join("manifest.json");
let manifest_raw = match std::fs::read_to_string(&manifest_path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempt == 0 => {
drop(lease);
continue;
}
Err(e) => return Err(crate::Error::Io(e)),
};
let manifest: SnapshotManifest = serde_json::from_str(&manifest_raw).map_err(|e| {
crate::Error::Index(IndexError::InvalidManifest {
path: manifest_path.clone(),
source: e,
})
})?;
let mut indexes = Vec::new();
for name in &manifest.indexes {
let kind: super::IndexKind = name
.parse()
.map_err(|_| crate::Error::Index(IndexError::UnknownIndexKind(name.clone())))?;
let index_dir = snap_dir.join(name);
indexes.push(kind.open(
super::IndexSource::Directory(&index_dir),
&root,
corpus_kind,
)?);
}
return Ok(super::snapshot::Snapshot::current(root, indexes, lease));
}
Err(crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"snapshot disappeared during open",
)))
}
}
impl<S: SnapshotStore> IndexStore<S> {
#[must_use]
pub fn current_id(&self) -> Option<&str> {
self.snapshots.current_id().map(SnapshotId::as_str)
}
pub fn build(
&mut self,
kinds: &[super::IndexKind],
config: &super::IndexConfig<'_>,
paths: &[PathBuf],
) -> crate::Result<String> {
let mut writer = self.snapshots.writer()?;
let mut txn = writer.begin()?;
for kind in kinds {
kind.build(
config,
super::IndexDestination::Snapshot {
writer: &mut txn,
namespace: kind.as_str(),
},
paths,
)?;
}
let manifest = SnapshotManifest {
id: txn.id().clone(),
indexes: kinds.iter().map(|k| k.as_str().to_string()).collect(),
};
let id = writer.publish(txn, manifest)?;
Ok(id.to_string())
}
pub fn update(
&mut self,
kinds: &[super::IndexKind],
config: &super::IndexConfig<'_>,
paths: &[PathBuf],
) -> crate::Result<Option<String>> {
let mut writer = self.snapshots.writer()?;
let Some(current) = writer.current()? else {
return Err(crate::Error::Index(IndexError::Io {
path: self.sift_dir.clone(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"no current snapshot; run build first",
),
}));
};
let mut txn = writer.begin()?;
let changed: Vec<bool> = kinds
.iter()
.map(|kind| {
kind.update(
super::IndexSource::Snapshot {
reader: ¤t as &dyn super::snapshot::SnapshotRead,
namespace: kind.as_str(),
},
config,
super::IndexDestination::Snapshot {
writer: &mut txn,
namespace: kind.as_str(),
},
paths,
)
})
.collect::<crate::Result<_>>()?;
if !changed.iter().any(|&c| c) {
return Ok(None);
}
for (kind, did_change) in kinds.iter().zip(&changed) {
if !did_change {
for artifact_name in kind.artifact_names() {
let data = current.artifact(kind.as_str(), artifact_name)?;
let bytes = data.as_ref().to_vec();
txn.put_artifact(kind.as_str(), artifact_name, bytes)?;
}
}
}
let manifest = SnapshotManifest {
id: txn.id().clone(),
indexes: kinds.iter().map(|k| k.as_str().to_string()).collect(),
};
let id = writer.publish(txn, manifest)?;
Ok(Some(id.to_string()))
}
pub fn reconcile(&mut self, meta: &StoreMeta, paths: &[PathBuf]) -> crate::Result<()> {
let config = meta.index_config();
let kinds = &meta.indexes;
if paths.is_empty() {
if self.current_id().is_none() {
self.build(kinds, &config, &[])?;
} else {
self.update(kinds, &config, &[])?;
}
} else if self.current_id().is_none() {
self.build(kinds, &config, paths)?;
} else {
self.update(kinds, &config, paths)?;
}
Ok(())
}
}
fn acquire_write_lock(sift_dir: &Path) -> crate::Result<WriteLockGuard> {
let lock_path = sift_dir.join("write.lock");
let mut lock_file = fslock::LockFile::open(&lock_path)?;
lock_file.lock()?;
Ok(WriteLockGuard { file: lock_file })
}
struct WriteLockGuard {
file: fslock::LockFile,
}
impl Drop for WriteLockGuard {
fn drop(&mut self) {
let _ = &mut self.file;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::config::IndexWalkConfig;
use crate::index::meta::{CorpusMeta, FilterMeta, StoreMeta, WalkMeta};
use crate::index::{CorpusKind, CorpusSpec, IndexConfig, IndexKind};
use crate::search::filter::VisibilityConfig;
use std::fs;
use tempfile::TempDir;
const MANIFEST_FILE: &str = "manifest.json";
fn test_meta(corpus: &Path) -> StoreMeta {
let root = corpus
.canonicalize()
.unwrap_or_else(|_| corpus.to_path_buf());
StoreMeta::new(
CorpusMeta {
root,
kind: CorpusKind::Directory,
include_paths: Vec::new(),
exclude_paths: Vec::new(),
},
WalkMeta {
follow_links: false,
one_file_system: false,
max_depth: None,
max_filesize: None,
},
FilterMeta {
visibility: VisibilityConfig::default(),
},
vec![IndexKind::Trigram],
)
}
fn test_config(corpus: &Path) -> IndexConfig<'_> {
IndexConfig {
corpus: CorpusSpec {
root: corpus,
kind: CorpusKind::Directory,
follow_links: false,
include_paths: &[],
exclude_paths: &[],
},
walk: IndexWalkConfig::new(false),
visibility: VisibilityConfig::default(),
}
}
fn open_test_store(corpus: &Path, sift_dir: &Path) -> IndexStore {
IndexStore::open_or_create(sift_dir, &test_meta(corpus)).expect("open store")
}
#[test]
fn build_creates_store_layout() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let mut store = open_test_store(&corpus, &sift_dir);
store
.build(&[IndexKind::Trigram], &test_config(&corpus), &[])
.expect("build");
assert!(StoreMeta::path(&sift_dir).exists());
let id = store.current_id().expect("has current id");
let snapshot_dir = store.snapshot_dir(id);
assert!(snapshot_dir.exists());
assert!(snapshot_dir.join(MANIFEST_FILE).exists());
assert!(snapshot_dir.join("trigram").exists());
assert!(snapshot_dir.join("trigram").join("files.bin").exists());
assert!(snapshot_dir.join("trigram").join("lexicon.bin").exists());
assert!(snapshot_dir.join("trigram").join("postings.bin").exists());
assert!(snapshot_dir.join("trigram").join("trigrams.bin").exists());
assert!(!id.is_empty());
}
#[test]
fn open_current_returns_indexes() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let mut store = open_test_store(&corpus, &sift_dir);
store
.build(&[IndexKind::Trigram], &test_config(&corpus), &[])
.expect("build");
drop(store);
let store = IndexStore::open(&sift_dir).expect("reopen store");
let snapshot = store.open_current().expect("open current");
assert_eq!(snapshot.indexes().len(), 1);
let canon_corpus = corpus.canonicalize().unwrap();
assert_eq!(snapshot.indexes()[0].root(), &canon_corpus);
assert!(store.current_id().is_some(), "store should have current id");
}
#[test]
fn open_returns_empty_when_no_current() {
let tmp = TempDir::new().expect("create temp dir");
let sift_dir = tmp.path().join(".sift");
std::fs::create_dir_all(&sift_dir).expect("create sift dir");
let store = IndexStore::open(&sift_dir).expect("open store");
assert!(store.current_id().is_none());
let snapshot = store.open_current().expect("open current");
assert!(snapshot.is_empty());
}
#[test]
fn open_without_sift_dir_returns_empty() {
let tmp = TempDir::new().expect("create temp dir");
let store = IndexStore::open(&tmp.path().join(".sift")).expect("open store");
assert!(store.current_id().is_none());
}
#[test]
fn update_skips_rebuild_when_unchanged() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let config = test_config(&corpus);
let mut store = open_test_store(&corpus, &sift_dir);
store
.build(&[IndexKind::Trigram], &config, &[])
.expect("build");
let id_after_build = store.current_id().expect("has id").to_string();
let changed = store
.update(&[IndexKind::Trigram], &config, &[])
.expect("update");
assert_eq!(changed, None, "expected no rebuild when corpus unchanged");
assert_eq!(store.current_id().unwrap(), id_after_build);
}
#[test]
fn update_rebuilds_when_file_added() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let config = test_config(&corpus);
let mut store = open_test_store(&corpus, &sift_dir);
store
.build(&[IndexKind::Trigram], &config, &[])
.expect("build");
let id_after_build = store.current_id().expect("has id").to_string();
fs::write(corpus.join("g.txt"), "new file\n").expect("write new file");
let changed = store
.update(&[IndexKind::Trigram], &config, &[])
.expect("update");
assert!(changed.is_some(), "expected rebuild when file added");
assert_ne!(store.current_id().unwrap(), id_after_build);
}
#[test]
fn update_errors_when_no_current_snapshot() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let config = test_config(&corpus);
let mut store = open_test_store(&corpus, &sift_dir);
let err = store
.update(&[IndexKind::Trigram], &config, &[])
.expect_err("update without snapshot");
assert!(matches!(err, crate::Error::Index(_)));
}
#[test]
fn build_acquires_write_lock() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "content\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let config = test_config(&corpus);
let mut store = open_test_store(&corpus, &sift_dir);
store
.build(&[IndexKind::Trigram], &config, &[])
.expect("build");
let lock_path = sift_dir.join("write.lock");
let mut lock = fslock::LockFile::open(&lock_path).expect("open lock");
assert!(
lock.try_lock().expect("try lock"),
"write lock should be released after build"
);
drop(lock);
}
#[test]
fn writer_refreshes_snapshot_state_on_acquire() {
let tmp = TempDir::new().expect("create temp dir");
let corpus = tmp.path().join("corpus");
fs::create_dir_all(&corpus).expect("create corpus");
fs::write(corpus.join("f.txt"), "hello\n").expect("write file");
let sift_dir = tmp.path().join(".sift");
let config = test_config(&corpus);
let mut store_a = open_test_store(&corpus, &sift_dir);
store_a
.build(&[IndexKind::Trigram], &config, &[])
.expect("build");
fs::write(corpus.join("g.txt"), "new\n").expect("write");
store_a
.update(&[IndexKind::Trigram], &config, &[])
.expect("update");
let mut store_b = IndexStore::open(&sift_dir).expect("open store");
assert_eq!(
store_b.current_id(),
store_a.current_id(),
"freshly-opened store_b should see store_a's latest snapshot"
);
let changed = store_b
.update(&[IndexKind::Trigram], &config, &[])
.expect("update");
assert_eq!(changed, None, "corpus unchanged after store_a update");
assert_eq!(
store_b.current_id(),
store_a.current_id(),
"store_b should still point to the same snapshot"
);
}
}