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