Skip to main content

sift_core/index/
store.rs

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
10/// Index lifecycle orchestrator backed by a [`SnapshotStore`] for atomic
11/// persistence and coordination.
12///
13/// Manages building, updating, and publishing snapshots that contain one or
14/// more index kinds. The `build` and `update` methods accept `&[IndexKind]`,
15/// so the store handles any combination of index types without index-specific
16/// logic.
17pub 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    /// Persist updated metadata and refresh the in-memory copy.
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if writing `meta.json` fails.
34    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    /// Open an existing store at `sift_dir`.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if `CURRENT` exists but cannot be read.
47    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    /// Open an existing store or create a new one at `sift_dir`.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the store directory cannot be created or metadata
62    /// cannot be written.
63    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    // ------------------------------------------------------------------
83    // Read path
84    // ------------------------------------------------------------------
85
86    /// Open the current snapshot, returning an immutable [`Snapshot`] with
87    /// its indexes opened and a reader lease held.
88    ///
89    /// Re-reads `CURRENT` from disk to ensure freshness. Retries once if
90    /// the snapshot disappears during opening.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the manifest is malformed, an index kind is
95    /// unknown, or the snapshot could not be opened after retry.
96    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(&current_id);
115
116            let lease = SnapshotLease::create_file(&self.sift_dir, &current_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
163// ------------------------------------------------------------------
164// Generic impl (works for any SnapshotStore)
165// ------------------------------------------------------------------
166
167impl<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    // ------------------------------------------------------------------
174    // Write path
175    // ------------------------------------------------------------------
176
177    /// Build a new snapshot using the given index kinds.
178    ///
179    /// Acquires the writer session, builds each index kind as artifacts, and
180    /// publishes the snapshot.
181    ///
182    /// Returns the snapshot id.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the writer session cannot be acquired, the index
187    /// build fails, or publishing fails.
188    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    /// Update the current snapshot, rebuilding only indexes whose corpus
217    /// changed.
218    ///
219    /// Acquires the writer session, checks for changes, copies unchanged
220    /// artifacts from the current snapshot, and publishes a new one.
221    ///
222    /// Returns the snapshot id if a new snapshot was published, or `None` if
223    /// no index changed.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if the writer session cannot be acquired, the current
228    /// snapshot cannot be opened, the update check fails, or publishing fails.
229    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: &current 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    /// Rebuild or update index files.
290    ///
291    /// `paths` empty → full corpus. Non-empty → partial rel-paths only.
292    ///
293    /// # Errors
294    ///
295    /// Propagates build/update failures from the underlying index kinds.
296    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
320/// Guard that releases the write lock when dropped.
321struct 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        // Verify lock was released after build by acquiring it externally.
541        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        // Store A builds.
561        let mut store_a = open_test_store(&corpus, &sift_dir);
562        store_a
563            .build(&[IndexKind::Trigram], &config, &[])
564            .expect("build");
565
566        // Store A publishes a new snapshot.
567        fs::write(corpus.join("g.txt"), "new\n").expect("write");
568        store_a
569            .update(&[IndexKind::Trigram], &config, &[])
570            .expect("update");
571
572        // Store B opens — it should see the same current (reads fresh from disk
573        // at open time, then caches).
574        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        // Store B acquires the write lock for its own update, which refreshes
582        // its snapshot state.  Since the corpus is unchanged, no new snapshot
583        // is published and current_id stays the same.
584        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}