1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use super::meta::StoreMeta;
6use super::snapshot::{self, SnapshotStore};
7use super::{CorpusKind, IndexError};
8
9const MANIFEST_FILE: &str = "manifest.json";
10
11#[derive(Debug, Serialize, Deserialize)]
14struct SnapshotManifest {
15 id: String,
16 indexes: Vec<String>,
17}
18
19pub struct IndexStore {
22 snapshots: SnapshotStore,
23 sift_dir: PathBuf,
24}
25
26impl IndexStore {
27 pub fn open(sift_dir: &Path) -> crate::Result<Self> {
33 let snapshots = SnapshotStore::open(sift_dir)?;
34 Ok(Self {
35 snapshots,
36 sift_dir: sift_dir.to_path_buf(),
37 })
38 }
39
40 pub fn open_or_create(
47 sift_dir: &Path,
48 root: &Path,
49 corpus_kind: CorpusKind,
50 follow_links: bool,
51 indexes: &[super::IndexKind],
52 ) -> crate::Result<Self> {
53 std::fs::create_dir_all(sift_dir)?;
54
55 if !StoreMeta::path(sift_dir).exists() {
56 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
57 let meta = StoreMeta::new(canonical_root, corpus_kind, follow_links, indexes.to_vec());
58 meta.write(sift_dir)?;
59 }
60
61 Self::open(sift_dir)
62 }
63
64 #[must_use]
65 pub fn current_id(&self) -> Option<&str> {
66 self.snapshots.current_id()
67 }
68
69 #[must_use]
70 pub fn snapshot_dir(&self, id: &str) -> PathBuf {
71 self.snapshots.current_dir().map_or_else(
72 || self.sift_dir.join("snapshots").join(id),
73 |d| {
74 let parent = d.parent().unwrap_or(&self.sift_dir);
75 parent.join(id)
76 },
77 )
78 }
79
80 pub fn build(
89 &mut self,
90 kinds: &[super::IndexKind],
91 config: &super::IndexConfig<'_>,
92 ) -> crate::Result<String> {
93 let snapshot = self.snapshots.begin()?;
94
95 for kind in kinds {
96 let index_dir = snapshot.dir().join(kind.as_str());
97 std::fs::create_dir_all(&index_dir)?;
98 kind.build(config, &index_dir)?;
99 }
100
101 Self::write_manifest(&snapshot, kinds)?;
102 let id = snapshot.id().to_string();
103 self.snapshots.commit(snapshot)?;
104 Ok(id)
105 }
106
107 pub fn update(
118 &mut self,
119 kinds: &[super::IndexKind],
120 config: &super::IndexConfig<'_>,
121 ) -> crate::Result<Option<String>> {
122 let Some(current_dir) = self.snapshots.current_dir() else {
123 let id = self.build(kinds, config)?;
124 return Ok(Some(id));
125 };
126
127 let snapshot = self.snapshots.begin()?;
128
129 let changed: Vec<bool> = kinds
130 .iter()
131 .map(|kind| kind.update(¤t_dir, config, &snapshot.dir().join(kind.as_str())))
132 .collect::<crate::Result<_>>()?;
133
134 if !changed.iter().any(|&c| c) {
135 return Ok(None);
136 }
137
138 for (kind, did_change) in kinds.iter().zip(&changed) {
139 if !did_change {
140 let src = current_dir.join(kind.as_str());
141 if src.exists() {
142 snapshot::copy_dir_contents(&src, &snapshot.dir().join(kind.as_str()))?;
143 }
144 }
145 }
146
147 Self::write_manifest(&snapshot, kinds)?;
148 let id = snapshot.id().to_string();
149 self.snapshots.commit(snapshot)?;
150 Ok(Some(id))
151 }
152
153 pub fn open_current(&self) -> crate::Result<Vec<super::Index>> {
162 let Some(snapshot_dir) = self.snapshots.current_dir() else {
163 return Ok(Vec::new());
164 };
165
166 let manifest_path = snapshot_dir.join(MANIFEST_FILE);
167 let manifest_raw = std::fs::read_to_string(&manifest_path)?;
168 let manifest: SnapshotManifest = serde_json::from_str(&manifest_raw).map_err(|e| {
169 crate::Error::Index(IndexError::InvalidManifest {
170 path: manifest_path.clone(),
171 source: e,
172 })
173 })?;
174
175 let meta = StoreMeta::read(&self.sift_dir)?;
176
177 let mut indexes = Vec::new();
178 for name in &manifest.indexes {
179 let kind: super::IndexKind = name
180 .parse()
181 .map_err(|_| crate::Error::Index(IndexError::UnknownIndexKind(name.clone())))?;
182 let index_dir = snapshot_dir.join(name);
183 indexes.push(kind.open_from_dir(&index_dir, &meta.root, meta.corpus_kind)?);
184 }
185
186 Ok(indexes)
187 }
188
189 fn write_manifest(
190 snapshot: &super::snapshot::Snapshot,
191 kinds: &[super::IndexKind],
192 ) -> crate::Result<()> {
193 let manifest = SnapshotManifest {
194 id: snapshot.id().to_string(),
195 indexes: kinds.iter().map(|k| k.as_str().to_string()).collect(),
196 };
197 let json = serde_json::to_vec_pretty(&manifest).map_err(|e| {
198 crate::Error::Index(IndexError::InvalidManifest {
199 path: snapshot.dir().join(MANIFEST_FILE),
200 source: e,
201 })
202 })?;
203 std::fs::write(snapshot.dir().join(MANIFEST_FILE), json)?;
204 Ok(())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::index::{CorpusKind, CorpusSpec, IndexConfig, IndexKind};
212 use crate::search::filter::VisibilityConfig;
213 use std::fs;
214 use tempfile::TempDir;
215
216 #[test]
217 fn build_creates_store_layout() {
218 let tmp = TempDir::new().expect("create temp dir");
219 let corpus = tmp.path().join("corpus");
220 fs::create_dir_all(&corpus).expect("create corpus");
221 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
222
223 let sift_dir = tmp.path().join(".sift");
224 let mut store = IndexStore::open_or_create(
225 &sift_dir,
226 &corpus,
227 CorpusKind::Directory,
228 false,
229 &[IndexKind::Trigram],
230 )
231 .expect("open store");
232
233 store
234 .build(
235 &[IndexKind::Trigram],
236 &IndexConfig {
237 corpus: CorpusSpec {
238 root: &corpus,
239 kind: CorpusKind::Directory,
240 follow_links: false,
241 include_paths: &[],
242 exclude_paths: &[],
243 },
244 visibility: VisibilityConfig::default(),
245 },
246 )
247 .expect("build");
248
249 assert!(StoreMeta::path(&sift_dir).exists());
250
251 let id = store.current_id().expect("has current id");
252 let snapshot_dir = store.snapshots.current_dir().expect("has snapshot dir");
253 assert!(snapshot_dir.exists());
254 assert!(snapshot_dir.join(MANIFEST_FILE).exists());
255 assert!(snapshot_dir.join("trigram").exists());
256 assert!(snapshot_dir.join("trigram").join("files.bin").exists());
257 assert!(snapshot_dir.join("trigram").join("lexicon.bin").exists());
258 assert!(snapshot_dir.join("trigram").join("postings.bin").exists());
259 assert!(snapshot_dir.join("trigram").join("trigrams.bin").exists());
260
261 assert!(!id.is_empty());
262 }
263
264 #[test]
265 fn open_current_returns_indexes() {
266 let tmp = TempDir::new().expect("create temp dir");
267 let corpus = tmp.path().join("corpus");
268 fs::create_dir_all(&corpus).expect("create corpus");
269 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
270
271 let sift_dir = tmp.path().join(".sift");
272 let mut store = IndexStore::open_or_create(
273 &sift_dir,
274 &corpus,
275 CorpusKind::Directory,
276 false,
277 &[IndexKind::Trigram],
278 )
279 .expect("open store");
280
281 store
282 .build(
283 &[IndexKind::Trigram],
284 &IndexConfig {
285 corpus: CorpusSpec {
286 root: &corpus,
287 kind: CorpusKind::Directory,
288 follow_links: false,
289 include_paths: &[],
290 exclude_paths: &[],
291 },
292 visibility: VisibilityConfig::default(),
293 },
294 )
295 .expect("build");
296
297 drop(store);
298 let store = IndexStore::open(&sift_dir).expect("reopen store");
299 let indexes = store.open_current().expect("open current");
300 assert_eq!(indexes.len(), 1);
301 let canon_corpus = corpus.canonicalize().unwrap();
302 assert_eq!(indexes[0].root(), &canon_corpus);
303 }
304
305 #[test]
306 fn open_returns_empty_when_no_current() {
307 let tmp = TempDir::new().expect("create temp dir");
308 let sift_dir = tmp.path().join(".sift");
309 std::fs::create_dir_all(&sift_dir).expect("create sift dir");
310
311 let store = IndexStore::open(&sift_dir).expect("open store");
312 assert!(store.current_id().is_none());
313 let indexes = store.open_current().expect("open current");
314 assert!(indexes.is_empty());
315 }
316
317 #[test]
318 fn open_without_sift_dir_returns_empty() {
319 let tmp = TempDir::new().expect("create temp dir");
320 let store = IndexStore::open(&tmp.path().join(".sift")).expect("open store");
321 assert!(store.current_id().is_none());
322 }
323
324 #[test]
325 fn update_skips_rebuild_when_unchanged() {
326 let tmp = TempDir::new().expect("create temp dir");
327 let corpus = tmp.path().join("corpus");
328 fs::create_dir_all(&corpus).expect("create corpus");
329 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
330
331 let sift_dir = tmp.path().join(".sift");
332 let config = IndexConfig {
333 corpus: CorpusSpec {
334 root: &corpus,
335 kind: CorpusKind::Directory,
336 follow_links: false,
337 include_paths: &[],
338 exclude_paths: &[],
339 },
340 visibility: VisibilityConfig::default(),
341 };
342
343 let mut store = IndexStore::open_or_create(
344 &sift_dir,
345 &corpus,
346 CorpusKind::Directory,
347 false,
348 &[IndexKind::Trigram],
349 )
350 .expect("open store");
351 store.build(&[IndexKind::Trigram], &config).expect("build");
352
353 let id_after_build = store.current_id().expect("has id").to_string();
354
355 let changed = store
356 .update(&[IndexKind::Trigram], &config)
357 .expect("update");
358 assert_eq!(changed, None, "expected no rebuild when corpus unchanged");
359 assert_eq!(store.current_id().unwrap(), id_after_build);
360 }
361
362 #[test]
363 fn update_rebuilds_when_file_added() {
364 let tmp = TempDir::new().expect("create temp dir");
365 let corpus = tmp.path().join("corpus");
366 fs::create_dir_all(&corpus).expect("create corpus");
367 fs::write(corpus.join("f.txt"), "hello world\n").expect("write file");
368
369 let sift_dir = tmp.path().join(".sift");
370 let config = IndexConfig {
371 corpus: CorpusSpec {
372 root: &corpus,
373 kind: CorpusKind::Directory,
374 follow_links: false,
375 include_paths: &[],
376 exclude_paths: &[],
377 },
378 visibility: VisibilityConfig::default(),
379 };
380
381 let mut store = IndexStore::open_or_create(
382 &sift_dir,
383 &corpus,
384 CorpusKind::Directory,
385 false,
386 &[IndexKind::Trigram],
387 )
388 .expect("open store");
389 store.build(&[IndexKind::Trigram], &config).expect("build");
390
391 let id_after_build = store.current_id().expect("has id").to_string();
392
393 fs::write(corpus.join("g.txt"), "new file\n").expect("write new file");
394
395 let changed = store
396 .update(&[IndexKind::Trigram], &config)
397 .expect("update");
398 assert!(changed.is_some(), "expected rebuild when file added");
399 assert_ne!(store.current_id().unwrap(), id_after_build);
400 }
401
402 #[test]
403 fn update_builds_when_no_current_snapshot() {
404 let tmp = TempDir::new().expect("create temp dir");
405 let corpus = tmp.path().join("corpus");
406 fs::create_dir_all(&corpus).expect("create corpus");
407 fs::write(corpus.join("f.txt"), "hello\n").expect("write file");
408
409 let sift_dir = tmp.path().join(".sift");
410 let config = IndexConfig {
411 corpus: CorpusSpec {
412 root: &corpus,
413 kind: CorpusKind::Directory,
414 follow_links: false,
415 include_paths: &[],
416 exclude_paths: &[],
417 },
418 visibility: VisibilityConfig::default(),
419 };
420
421 let mut store = IndexStore::open_or_create(
422 &sift_dir,
423 &corpus,
424 CorpusKind::Directory,
425 false,
426 &[IndexKind::Trigram],
427 )
428 .expect("open store");
429
430 let changed = store
431 .update(&[IndexKind::Trigram], &config)
432 .expect("update");
433 assert!(changed.is_some(), "expected build when no snapshot exists");
434 assert!(store.current_id().is_some());
435 }
436}