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.
12pub 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    /// Persist updated metadata and refresh the in-memory copy.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error if writing `meta.json` fails.
29    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    /// Open an existing store at `sift_dir`.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if `CURRENT` exists but cannot be read.
42    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    /// Open an existing store or create a new one at `sift_dir`.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if the store directory cannot be created or metadata
57    /// cannot be written.
58    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    // ------------------------------------------------------------------
78    // Read path
79    // ------------------------------------------------------------------
80
81    /// Open the current snapshot, returning an immutable [`Snapshot`] with
82    /// its indexes opened and a reader lease held.
83    ///
84    /// Re-reads `CURRENT` from disk to ensure freshness. Retries once if
85    /// the snapshot disappears during opening.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the manifest is malformed, an index kind is
90    /// unknown, or the snapshot could not be opened after retry.
91    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(&current_id);
110
111            let lease = SnapshotLease::create_file(&self.sift_dir, &current_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
158// ------------------------------------------------------------------
159// Generic impl (works for any SnapshotStore)
160// ------------------------------------------------------------------
161
162impl<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    // ------------------------------------------------------------------
169    // Write path
170    // ------------------------------------------------------------------
171
172    /// Build a new snapshot using the given index kinds.
173    ///
174    /// Acquires the writer session, builds each index kind as artifacts, and
175    /// publishes the snapshot.
176    ///
177    /// Returns the snapshot id.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the writer session cannot be acquired, the index
182    /// build fails, or publishing fails.
183    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    /// Update the current snapshot, rebuilding only indexes whose corpus
212    /// changed.
213    ///
214    /// Acquires the writer session, checks for changes, copies unchanged
215    /// artifacts from the current snapshot, and publishes a new one.
216    ///
217    /// Returns the snapshot id if a new snapshot was published, or `None` if
218    /// no index changed.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the writer session cannot be acquired, the current
223    /// snapshot cannot be opened, the update check fails, or publishing fails.
224    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: &current 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    /// Rebuild or update index files.
285    ///
286    /// `paths` empty → full corpus. Non-empty → partial rel-paths only.
287    ///
288    /// # Errors
289    ///
290    /// Propagates build/update failures from the underlying index kinds.
291    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
315/// Guard that releases the write lock when dropped.
316struct 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        // Verify lock was released after build by acquiring it externally.
536        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        // Store A builds.
556        let mut store_a = open_test_store(&corpus, &sift_dir);
557        store_a
558            .build(&[IndexKind::Trigram], &config, &[])
559            .expect("build");
560
561        // Store A publishes a new snapshot.
562        fs::write(corpus.join("g.txt"), "new\n").expect("write");
563        store_a
564            .update(&[IndexKind::Trigram], &config, &[])
565            .expect("update");
566
567        // Store B opens — it should see the same current (reads fresh from disk
568        // at open time, then caches).
569        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        // Store B acquires the write lock for its own update, which refreshes
577        // its snapshot state.  Since the corpus is unchanged, no new snapshot
578        // is published and current_id stays the same.
579        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}