Skip to main content

sift_core/index/
mod.rs

1pub mod meta;
2mod snapshot;
3pub mod store;
4pub mod trigram;
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::search::filter::VisibilityConfig;
12use crate::search::output::mode::CandidateCoverage;
13
14pub use trigram::TrigramIndexError;
15
16/// How an index query plan resolves candidates.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18pub enum PlanMode {
19    /// The query was narrowed using trigram candidates from the index.
20    #[default]
21    IndexedCandidates,
22    /// No trigrams were usable — all indexed files must be scanned.
23    FullScan,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct QueryPlanOutput {
28    pub pattern: String,
29    pub mode: PlanMode,
30}
31
32/// Whether the index was built from a directory or a single file.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34pub enum CorpusKind {
35    /// Built from a directory path — all discovered files were indexed.
36    #[default]
37    Directory,
38    /// Built from a single file path — only that file was indexed.
39    SingleFile,
40}
41
42/// Configuration for building or updating an index over a corpus.
43pub struct IndexConfig<'a> {
44    pub corpus: CorpusSpec<'a>,
45    pub visibility: VisibilityConfig,
46}
47
48/// Description of a corpus to index.
49pub struct CorpusSpec<'a> {
50    pub root: &'a Path,
51    pub kind: CorpusKind,
52    pub follow_links: bool,
53    pub include_paths: &'a [PathBuf],
54    pub exclude_paths: &'a [PathBuf],
55}
56
57/// Errors specific to the index registry layer.
58#[derive(Debug, thiserror::Error)]
59pub enum IndexError {
60    #[error("invalid index layout: {path}")]
61    InvalidLayout { path: PathBuf },
62
63    #[error(transparent)]
64    Trigram(#[from] TrigramIndexError),
65
66    #[error("IO error inspecting index path {path}: {source}")]
67    Io {
68        path: PathBuf,
69        source: std::io::Error,
70    },
71
72    #[error("unknown index kind: {0}")]
73    UnknownIndexKind(String),
74
75    #[error("invalid snapshot manifest at {path}: {source}")]
76    InvalidManifest {
77        path: PathBuf,
78        source: serde_json::Error,
79    },
80}
81
82/// Tag identifying an index kind for lifecycle dispatch (build, open, update).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum IndexKind {
86    Trigram,
87}
88
89impl IndexKind {
90    pub const ALL: &[Self] = &[Self::Trigram];
91
92    #[must_use]
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Trigram => "trigram",
96        }
97    }
98
99    pub(crate) fn build(self, config: &IndexConfig<'_>, output_dir: &Path) -> crate::Result<()> {
100        match self {
101            Self::Trigram => {
102                trigram::TrigramIndex::build(config, output_dir)?;
103                Ok(())
104            }
105        }
106    }
107
108    pub(crate) fn open_from_dir(
109        self,
110        index_dir: &Path,
111        root: &Path,
112        corpus_kind: CorpusKind,
113    ) -> crate::Result<Index> {
114        match self {
115            Self::Trigram => Ok(Index::Trigram(trigram::TrigramIndex::open(
116                index_dir,
117                root,
118                corpus_kind,
119            )?)),
120        }
121    }
122
123    /// Returns `true` if a new index was written.
124    pub(crate) fn update(
125        self,
126        snapshot_dir: &Path,
127        config: &IndexConfig<'_>,
128        output_dir: &Path,
129    ) -> crate::Result<bool> {
130        let existing_dir = snapshot_dir.join(self.as_str());
131        if !existing_dir.exists() {
132            self.build(config, output_dir)?;
133            return Ok(true);
134        }
135        let root = config
136            .corpus
137            .root
138            .canonicalize()
139            .unwrap_or_else(|_| config.corpus.root.to_path_buf());
140        match self {
141            Self::Trigram => {
142                let existing =
143                    trigram::TrigramIndex::open(&existing_dir, &root, config.corpus.kind)?;
144                Ok(existing.update(config, output_dir)?.is_some())
145            }
146        }
147    }
148}
149
150impl std::fmt::Display for IndexKind {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(self.as_str())
153    }
154}
155
156impl std::str::FromStr for IndexKind {
157    type Err = String;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        match s {
161            "trigram" => Ok(Self::Trigram),
162            other => Err(format!("unknown index kind: {other}")),
163        }
164    }
165}
166
167/// An opened index instance, used for search-time dispatch.
168pub enum Index {
169    Trigram(trigram::TrigramIndex),
170}
171
172impl Index {
173    #[must_use]
174    pub fn root(&self) -> &Path {
175        match self {
176            Self::Trigram(idx) => idx.root(),
177        }
178    }
179
180    #[must_use]
181    pub const fn corpus_kind(&self) -> CorpusKind {
182        match self {
183            Self::Trigram(idx) => idx.corpus_kind(),
184        }
185    }
186
187    #[must_use]
188    pub fn candidates(&self, query: &crate::query::QuerySpec<'_>) -> Vec<crate::Candidate> {
189        match self {
190            Self::Trigram(idx) => idx.candidates(query),
191        }
192    }
193
194    #[must_use]
195    pub fn all_files(&self) -> Vec<crate::Candidate> {
196        match self {
197            Self::Trigram(idx) => idx.all_files(),
198        }
199    }
200}
201
202/// Registry of opened indexes read from a snapshot store.
203pub struct Indexes {
204    inner: Vec<Index>,
205    root: PathBuf,
206}
207
208impl Indexes {
209    /// Create an Indexes registry from a single index and its root.
210    ///
211    /// Useful for testing and benchmarking.
212    #[must_use]
213    pub fn from_single(index: Index, root: PathBuf) -> Self {
214        Self {
215            inner: vec![index],
216            root,
217        }
218    }
219
220    /// Open all indexes found under `sift_dir`.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`IndexError::InvalidManifest`] if a snapshot manifest is
225    /// malformed, or [`IndexError::Trigram`] if a trigram index is malformed.
226    ///
227    /// Returns an empty registry if no current snapshot exists (walk fallback).
228    pub fn open(sift_dir: &Path) -> Result<Self, IndexError> {
229        let store = store::IndexStore::open(sift_dir).map_err(|e| match e {
230            crate::Error::Index(ie) => ie,
231            crate::Error::Io(io) => IndexError::Io {
232                path: sift_dir.to_path_buf(),
233                source: io,
234            },
235            _ => IndexError::Io {
236                path: sift_dir.to_path_buf(),
237                source: std::io::Error::other(e.to_string()),
238            },
239        })?;
240
241        let inner = store.open_current().map_err(|e| match e {
242            crate::Error::Index(ie) => ie,
243            crate::Error::Io(io) => IndexError::Io {
244                path: sift_dir.to_path_buf(),
245                source: io,
246            },
247            _ => IndexError::Io {
248                path: sift_dir.to_path_buf(),
249                source: std::io::Error::other(e.to_string()),
250            },
251        })?;
252
253        let root = meta::StoreMeta::read(sift_dir)
254            .ok()
255            .map(|m| m.root)
256            .unwrap_or_default();
257
258        Ok(Self { inner, root })
259    }
260
261    #[must_use]
262    pub const fn is_empty(&self) -> bool {
263        self.inner.is_empty()
264    }
265
266    #[must_use]
267    pub fn root(&self) -> &Path {
268        &self.root
269    }
270
271    /// Resolve candidates for a query across all registered indexes.
272    ///
273    /// Conservative sets from each [`SearchIndex`] are intersected using
274    /// `Candidate::rel_path` equality (no hashing), so distinct paths are never
275    /// merged by accident.
276    #[must_use]
277    pub fn resolve_candidates(&self, query: &crate::query::QuerySpec<'_>) -> Vec<crate::Candidate> {
278        let mut iter = self.inner.iter();
279        let Some(first) = iter.next() else {
280            return Vec::new();
281        };
282
283        let mut candidates = first.candidates(query);
284
285        for index in iter {
286            let next: HashSet<PathBuf> = index
287                .candidates(query)
288                .into_iter()
289                .map(|c| c.rel_path().to_path_buf())
290                .collect();
291            candidates.retain(|c| next.contains(c.rel_path()));
292            if candidates.is_empty() {
293                break;
294            }
295        }
296
297        candidates
298    }
299
300    /// Resolve candidates for a query, selecting narrowed or complete coverage.
301    #[must_use]
302    pub fn candidates(
303        &self,
304        query: &crate::query::QuerySpec<'_>,
305        coverage: CandidateCoverage,
306    ) -> Vec<crate::Candidate> {
307        match coverage {
308            CandidateCoverage::Narrowed => self.resolve_candidates(query),
309            CandidateCoverage::Complete => self.resolve_all_files(),
310        }
311    }
312
313    /// Return all indexed files across all registered indexes.
314    #[must_use]
315    pub fn resolve_all_files(&self) -> Vec<crate::Candidate> {
316        let mut iter = self.inner.iter();
317        let Some(first) = iter.next() else {
318            return Vec::new();
319        };
320
321        let mut files = first.all_files();
322
323        for index in iter {
324            let next: HashSet<PathBuf> = index
325                .all_files()
326                .into_iter()
327                .map(|c| c.rel_path().to_path_buf())
328                .collect();
329            files.retain(|c| next.contains(c.rel_path()));
330            if files.is_empty() {
331                break;
332            }
333        }
334
335        files
336    }
337
338    #[must_use]
339    pub fn first(&self) -> Option<&Index> {
340        self.inner.first()
341    }
342
343    /// Returns the corpus kind if all indexes agree, or `None` for mixed/empty.
344    #[must_use]
345    pub fn corpus_kind(&self) -> Option<CorpusKind> {
346        let kind = self.inner.first()?.corpus_kind();
347        if self.inner.iter().any(|idx| idx.corpus_kind() != kind) {
348            return None;
349        }
350        Some(kind)
351    }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
355pub struct FileId(usize);
356
357impl FileId {
358    #[must_use]
359    pub const fn new(value: usize) -> Self {
360        Self(value)
361    }
362
363    #[must_use]
364    pub const fn get(self) -> usize {
365        self.0
366    }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
370pub struct IndexId(usize);
371
372impl IndexId {
373    #[must_use]
374    pub const fn new(value: usize) -> Self {
375        Self(value)
376    }
377
378    #[must_use]
379    pub const fn get(self) -> usize {
380        self.0
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::fs;
388    use tempfile::TempDir;
389
390    #[test]
391    fn file_id_new_and_get() {
392        let id = FileId::new(42);
393        assert_eq!(id.get(), 42);
394    }
395
396    #[test]
397    fn index_id_new_and_get() {
398        let id = IndexId::new(7);
399        assert_eq!(id.get(), 7);
400    }
401
402    #[test]
403    fn indexes_open_empty_when_no_current_file() {
404        let tmp = TempDir::new().expect("create temp dir");
405        let sift_dir = tmp.path().join(".sift");
406        fs::create_dir_all(&sift_dir).expect("create sift dir");
407        let indexes = Indexes::open(&sift_dir).expect("open indexes");
408        assert!(indexes.is_empty());
409        assert!(indexes.root().as_os_str().is_empty());
410    }
411
412    #[test]
413    fn indexes_first_returns_none_when_empty() {
414        let tmp = TempDir::new().expect("create temp dir");
415        let sift_dir = tmp.path().join(".sift");
416        fs::create_dir_all(&sift_dir).expect("create sift dir");
417        let indexes = Indexes::open(&sift_dir).expect("open indexes");
418        assert!(indexes.first().is_none());
419    }
420}