1use std::path::{Path, PathBuf};
2
3use super::IndexError;
4use super::meta::StoreMeta;
5use super::snapshot::{
6 DiskSnapshotStore, SnapshotId, SnapshotLease, SnapshotManifest, SnapshotRead, SnapshotStore,
7 SnapshotWrite, SnapshotWriterSession,
8};
9
10pub struct IndexStore<S: SnapshotStore = DiskSnapshotStore> {
18 snapshots: S,
19 sift_dir: PathBuf,
20 meta: Option<StoreMeta>,
21}
22
23impl IndexStore<DiskSnapshotStore> {
24 #[must_use]
25 pub const fn meta(&self) -> Option<&StoreMeta> {
26 self.meta.as_ref()
27 }
28
29 pub fn refresh_meta(&mut self, meta: &StoreMeta) -> crate::Result<()> {
35 meta.write(&self.sift_dir)?;
36 self.meta = Some(meta.clone());
37 Ok(())
38 }
39}
40
41impl IndexStore<DiskSnapshotStore> {
42 pub fn open(sift_dir: &Path) -> crate::Result<Self> {
48 let snapshots = DiskSnapshotStore::open(sift_dir)?;
49 let meta = StoreMeta::read(sift_dir).ok();
50 Ok(Self {
51 snapshots,
52 sift_dir: sift_dir.to_path_buf(),
53 meta,
54 })
55 }
56
57 pub fn open_or_create(sift_dir: &Path, meta: &StoreMeta) -> crate::Result<Self> {
64 std::fs::create_dir_all(sift_dir)?;
65
66 if !StoreMeta::path(sift_dir).exists() {
67 let guard = acquire_write_lock(sift_dir)?;
68 if !StoreMeta::path(sift_dir).exists() {
69 meta.write(sift_dir)?;
70 }
71 drop(guard);
72 }
73
74 Self::open(sift_dir)
75 }
76
77 #[must_use]
78 pub fn snapshot_dir(&self, id: &str) -> PathBuf {
79 self.sift_dir.join("snapshots").join(id)
80 }
81
82 pub(crate) fn open_current(&self) -> crate::Result<super::snapshot::Snapshot> {
97 for attempt in 0..2 {
98 let Some(current_id) = DiskSnapshotStore::read_current_id(&self.sift_dir)? else {
99 return Ok(super::snapshot::Snapshot::empty(PathBuf::new()));
100 };
101
102 let Some(ref meta) = self.meta else {
103 return Err(crate::Error::Index(IndexError::Io {
104 path: self.sift_dir.join("sift.meta"),
105 source: std::io::Error::new(
106 std::io::ErrorKind::NotFound,
107 "store metadata not found",
108 ),
109 }));
110 };
111 let root = meta.corpus.root.clone();
112 let corpus_kind = meta.corpus.kind;
113
114 let snap_dir = self.sift_dir.join("snapshots").join(¤t_id);
115
116 let lease = SnapshotLease::create_file(&self.sift_dir, ¤t_id)?;
117
118 if !snap_dir.exists() {
119 drop(lease);
120 continue;
121 }
122
123 let manifest_path = snap_dir.join("manifest.json");
124 let manifest_raw = match std::fs::read_to_string(&manifest_path) {
125 Ok(raw) => raw,
126 Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempt == 0 => {
127 drop(lease);
128 continue;
129 }
130 Err(e) => return Err(crate::Error::Io(e)),
131 };
132
133 let manifest: SnapshotManifest = serde_json::from_str(&manifest_raw).map_err(|e| {
134 crate::Error::Index(IndexError::InvalidManifest {
135 path: manifest_path.clone(),
136 source: e,
137 })
138 })?;
139
140 let mut indexes = Vec::new();
141 for name in &manifest.indexes {
142 let kind: super::IndexKind = name
143 .parse()
144 .map_err(|_| crate::Error::Index(IndexError::UnknownIndexKind(name.clone())))?;
145 let index_dir = snap_dir.join(name);
146 indexes.push(kind.open(
147 super::IndexSource::Directory(&index_dir),
148 &root,
149 corpus_kind,
150 )?);
151 }
152
153 return Ok(super::snapshot::Snapshot::current(root, indexes, lease));
154 }
155
156 Err(crate::Error::Io(std::io::Error::new(
157 std::io::ErrorKind::NotFound,
158 "snapshot disappeared during open",
159 )))
160 }
161}
162
163impl<S: SnapshotStore> IndexStore<S> {
168 #[must_use]
169 pub fn current_id(&self) -> Option<&str> {
170 self.snapshots.current_id().map(SnapshotId::as_str)
171 }
172
173 pub fn build(
189 &mut self,
190 kinds: &[super::IndexKind],
191 config: &super::IndexConfig<'_>,
192 paths: &[PathBuf],
193 ) -> crate::Result<String> {
194 let mut writer = self.snapshots.writer()?;
195 let mut txn = writer.begin()?;
196
197 for kind in kinds {
198 kind.build(
199 config,
200 super::IndexDestination::Snapshot {
201 writer: &mut txn,
202 namespace: kind.as_str(),
203 },
204 paths,
205 )?;
206 }
207
208 let manifest = SnapshotManifest {
209 id: txn.id().clone(),
210 indexes: kinds.iter().map(|k| k.as_str().to_string()).collect(),
211 };
212 let id = writer.publish(txn, manifest)?;
213 Ok(id.to_string())
214 }
215
216 pub fn update(
230 &mut self,
231 kinds: &[super::IndexKind],
232 config: &super::IndexConfig<'_>,
233 paths: &[PathBuf],
234 ) -> crate::Result<Option<String>> {
235 let mut writer = self.snapshots.writer()?;
236
237 let Some(current) = writer.current()? else {
238 return Err(crate::Error::Index(IndexError::Io {
239 path: self.sift_dir.clone(),
240 source: std::io::Error::new(
241 std::io::ErrorKind::NotFound,
242 "no current snapshot; run build first",
243 ),
244 }));
245 };
246
247 let mut txn = writer.begin()?;
248
249 let changed: Vec<bool> = kinds
250 .iter()
251 .map(|kind| {
252 kind.update(
253 super::IndexSource::Snapshot {
254 reader: ¤t as &dyn super::snapshot::SnapshotRead,
255 namespace: kind.as_str(),
256 },
257 config,
258 super::IndexDestination::Snapshot {
259 writer: &mut txn,
260 namespace: kind.as_str(),
261 },
262 paths,
263 )
264 })
265 .collect::<crate::Result<_>>()?;
266
267 if !changed.iter().any(|&c| c) {
268 return Ok(None);
269 }
270
271 for (kind, did_change) in kinds.iter().zip(&changed) {
272 if !did_change {
273 for artifact_name in kind.artifact_names() {
274 let data = current.artifact(kind.as_str(), artifact_name)?;
275 let bytes = data.as_ref().to_vec();
276 txn.put_artifact(kind.as_str(), artifact_name, bytes)?;
277 }
278 }
279 }
280
281 let manifest = SnapshotManifest {
282 id: txn.id().clone(),
283 indexes: kinds.iter().map(|k| k.as_str().to_string()).collect(),
284 };
285 let id = writer.publish(txn, manifest)?;
286 Ok(Some(id.to_string()))
287 }
288
289 pub fn reconcile(&mut self, meta: &StoreMeta, paths: &[PathBuf]) -> crate::Result<()> {
297 let config = meta.index_config();
298 let kinds = &meta.indexes;
299 if paths.is_empty() {
300 if self.current_id().is_none() {
301 self.build(kinds, &config, &[])?;
302 } else {
303 self.update(kinds, &config, &[])?;
304 }
305 } else if self.current_id().is_none() {
306 self.build(kinds, &config, paths)?;
307 } else {
308 self.update(kinds, &config, paths)?;
309 }
310 Ok(())
311 }
312}
313fn acquire_write_lock(sift_dir: &Path) -> crate::Result<WriteLockGuard> {
314 let lock_path = sift_dir.join("write.lock");
315 let mut lock_file = fslock::LockFile::open(&lock_path)?;
316 lock_file.lock()?;
317 Ok(WriteLockGuard { file: lock_file })
318}
319
320struct WriteLockGuard {
322 file: fslock::LockFile,
323}
324
325impl Drop for WriteLockGuard {
326 fn drop(&mut self) {
327 let _ = &mut self.file;
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::index::config::IndexWalkConfig;
335 use crate::index::meta::{CorpusMeta, FilterMeta, StoreMeta, WalkMeta};
336 use crate::index::{CorpusKind, CorpusSpec, IndexConfig, IndexKind};
337 use crate::search::filter::VisibilityConfig;
338 use std::fs;
339 use tempfile::TempDir;
340
341 const MANIFEST_FILE: &str = "manifest.json";
342
343 fn test_meta(corpus: &Path) -> StoreMeta {
344 let root = corpus
345 .canonicalize()
346 .unwrap_or_else(|_| corpus.to_path_buf());
347 StoreMeta::new(
348 CorpusMeta {
349 root,
350 kind: CorpusKind::Directory,
351 include_paths: Vec::new(),
352 exclude_paths: Vec::new(),
353 },
354 WalkMeta {
355 follow_links: false,
356 one_file_system: false,
357 max_depth: None,
358 max_filesize: None,
359 },
360 FilterMeta {
361 visibility: VisibilityConfig::default(),
362 },
363 vec![IndexKind::Trigram],
364 )
365 }
366
367 fn test_config(corpus: &Path) -> IndexConfig<'_> {
368 IndexConfig {
369 corpus: CorpusSpec {
370 root: corpus,
371 kind: CorpusKind::Directory,
372 follow_links: false,
373 include_paths: &[],
374 exclude_paths: &[],
375 },
376 walk: IndexWalkConfig::new(false),
377 visibility: VisibilityConfig::default(),
378 }
379 }
380
381 fn open_test_store(corpus: &Path, sift_dir: &Path) -> IndexStore {
382 IndexStore::open_or_create(sift_dir, &test_meta(corpus)).expect("open store")
383 }
384
385 #[test]
386 fn build_creates_store_layout() {
387 let tmp = TempDir::new().expect("create temp dir");
388 let corpus = tmp.path().join("corpus");
389 fs::create_dir_all(&corpus).expect("create corpus");
390 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
391
392 let sift_dir = tmp.path().join(".sift");
393 let mut store = open_test_store(&corpus, &sift_dir);
394
395 store
396 .build(&[IndexKind::Trigram], &test_config(&corpus), &[])
397 .expect("build");
398
399 assert!(StoreMeta::path(&sift_dir).exists());
400
401 let id = store.current_id().expect("has current id");
402 let snapshot_dir = store.snapshot_dir(id);
403 assert!(snapshot_dir.exists());
404 assert!(snapshot_dir.join(MANIFEST_FILE).exists());
405 assert!(snapshot_dir.join("trigram").exists());
406 assert!(snapshot_dir.join("trigram").join("files.bin").exists());
407 assert!(snapshot_dir.join("trigram").join("lexicon.bin").exists());
408 assert!(snapshot_dir.join("trigram").join("postings.bin").exists());
409 assert!(snapshot_dir.join("trigram").join("trigrams.bin").exists());
410
411 assert!(!id.is_empty());
412 }
413
414 #[test]
415 fn open_current_returns_indexes() {
416 let tmp = TempDir::new().expect("create temp dir");
417 let corpus = tmp.path().join("corpus");
418 fs::create_dir_all(&corpus).expect("create corpus");
419 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
420
421 let sift_dir = tmp.path().join(".sift");
422 let mut store = open_test_store(&corpus, &sift_dir);
423
424 store
425 .build(&[IndexKind::Trigram], &test_config(&corpus), &[])
426 .expect("build");
427
428 drop(store);
429 let store = IndexStore::open(&sift_dir).expect("reopen store");
430 let snapshot = store.open_current().expect("open current");
431 assert_eq!(snapshot.indexes().len(), 1);
432 let canon_corpus = corpus.canonicalize().unwrap();
433 assert_eq!(snapshot.indexes()[0].root(), &canon_corpus);
434 assert!(store.current_id().is_some(), "store should have current id");
435 }
436
437 #[test]
438 fn open_returns_empty_when_no_current() {
439 let tmp = TempDir::new().expect("create temp dir");
440 let sift_dir = tmp.path().join(".sift");
441 std::fs::create_dir_all(&sift_dir).expect("create sift dir");
442
443 let store = IndexStore::open(&sift_dir).expect("open store");
444 assert!(store.current_id().is_none());
445 let snapshot = store.open_current().expect("open current");
446 assert!(snapshot.is_empty());
447 }
448
449 #[test]
450 fn open_without_sift_dir_returns_empty() {
451 let tmp = TempDir::new().expect("create temp dir");
452 let store = IndexStore::open(&tmp.path().join(".sift")).expect("open store");
453 assert!(store.current_id().is_none());
454 }
455
456 #[test]
457 fn update_skips_rebuild_when_unchanged() {
458 let tmp = TempDir::new().expect("create temp dir");
459 let corpus = tmp.path().join("corpus");
460 fs::create_dir_all(&corpus).expect("create corpus");
461 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
462
463 let sift_dir = tmp.path().join(".sift");
464 let config = test_config(&corpus);
465
466 let mut store = open_test_store(&corpus, &sift_dir);
467 store
468 .build(&[IndexKind::Trigram], &config, &[])
469 .expect("build");
470
471 let id_after_build = store.current_id().expect("has id").to_string();
472
473 let changed = store
474 .update(&[IndexKind::Trigram], &config, &[])
475 .expect("update");
476 assert_eq!(changed, None, "expected no rebuild when corpus unchanged");
477 assert_eq!(store.current_id().unwrap(), id_after_build);
478 }
479
480 #[test]
481 fn update_rebuilds_when_file_added() {
482 let tmp = TempDir::new().expect("create temp dir");
483 let corpus = tmp.path().join("corpus");
484 fs::create_dir_all(&corpus).expect("create corpus");
485 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
486
487 let sift_dir = tmp.path().join(".sift");
488 let config = test_config(&corpus);
489
490 let mut store = open_test_store(&corpus, &sift_dir);
491 store
492 .build(&[IndexKind::Trigram], &config, &[])
493 .expect("build");
494
495 let id_after_build = store.current_id().expect("has id").to_string();
496
497 fs::write(corpus.join("g.txt"), "new file\n").expect("write new file");
498
499 let changed = store
500 .update(&[IndexKind::Trigram], &config, &[])
501 .expect("update");
502 assert!(changed.is_some(), "expected rebuild when file added");
503 assert_ne!(store.current_id().unwrap(), id_after_build);
504 }
505
506 #[test]
507 fn update_errors_when_no_current_snapshot() {
508 let tmp = TempDir::new().expect("create temp dir");
509 let corpus = tmp.path().join("corpus");
510 fs::create_dir_all(&corpus).expect("create corpus");
511 fs::write(corpus.join("f.txt"), "hello\n").expect("write file");
512
513 let sift_dir = tmp.path().join(".sift");
514 let config = test_config(&corpus);
515
516 let mut store = open_test_store(&corpus, &sift_dir);
517
518 let err = store
519 .update(&[IndexKind::Trigram], &config, &[])
520 .expect_err("update without snapshot");
521 assert!(matches!(err, crate::Error::Index(_)));
522 }
523
524 #[test]
525 fn build_acquires_write_lock() {
526 let tmp = TempDir::new().expect("create temp dir");
527 let corpus = tmp.path().join("corpus");
528 fs::create_dir_all(&corpus).expect("create corpus");
529 fs::write(corpus.join("f.txt"), "content\n").expect("write file");
530
531 let sift_dir = tmp.path().join(".sift");
532 let config = test_config(&corpus);
533
534 let mut store = open_test_store(&corpus, &sift_dir);
535
536 store
537 .build(&[IndexKind::Trigram], &config, &[])
538 .expect("build");
539
540 let lock_path = sift_dir.join("write.lock");
542 let mut lock = fslock::LockFile::open(&lock_path).expect("open lock");
543 assert!(
544 lock.try_lock().expect("try lock"),
545 "write lock should be released after build"
546 );
547 drop(lock);
548 }
549
550 #[test]
551 fn writer_refreshes_snapshot_state_on_acquire() {
552 let tmp = TempDir::new().expect("create temp dir");
553 let corpus = tmp.path().join("corpus");
554 fs::create_dir_all(&corpus).expect("create corpus");
555 fs::write(corpus.join("f.txt"), "hello\n").expect("write file");
556
557 let sift_dir = tmp.path().join(".sift");
558 let config = test_config(&corpus);
559
560 let mut store_a = open_test_store(&corpus, &sift_dir);
562 store_a
563 .build(&[IndexKind::Trigram], &config, &[])
564 .expect("build");
565
566 fs::write(corpus.join("g.txt"), "new\n").expect("write");
568 store_a
569 .update(&[IndexKind::Trigram], &config, &[])
570 .expect("update");
571
572 let mut store_b = IndexStore::open(&sift_dir).expect("open store");
575 assert_eq!(
576 store_b.current_id(),
577 store_a.current_id(),
578 "freshly-opened store_b should see store_a's latest snapshot"
579 );
580
581 let changed = store_b
585 .update(&[IndexKind::Trigram], &config, &[])
586 .expect("update");
587 assert_eq!(changed, None, "corpus unchanged after store_a update");
588 assert_eq!(
589 store_b.current_id(),
590 store_a.current_id(),
591 "store_b should still point to the same snapshot"
592 );
593 }
594}