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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18pub enum PlanMode {
19 #[default]
21 IndexedCandidates,
22 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34pub enum CorpusKind {
35 #[default]
37 Directory,
38 SingleFile,
40}
41
42pub struct IndexConfig<'a> {
44 pub corpus: CorpusSpec<'a>,
45 pub visibility: VisibilityConfig,
46}
47
48pub 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#[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#[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 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
167pub 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
202pub struct Indexes {
204 inner: Vec<Index>,
205 root: PathBuf,
206}
207
208impl Indexes {
209 #[must_use]
213 pub fn from_single(index: Index, root: PathBuf) -> Self {
214 Self {
215 inner: vec![index],
216 root,
217 }
218 }
219
220 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 #[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 #[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 #[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 #[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}