Skip to main content

vtcode_indexer/
lib.rs

1#![allow(missing_docs)]
2//! Workspace-friendly file indexer and file utilities for VT Code.
3//!
4//! `vtcode-indexer` provides:
5//! - A lightweight workspace file indexer with markdown-backed persistence
6//! - Fast parallel fuzzy file search (via `file_search` module)
7//! - Markdown-backed storage utilities (via `markdown_store` module)
8
9pub mod file_search;
10pub mod markdown_store;
11
12use anyhow::Result;
13use hashbrown::HashMap;
14use ignore::{DirEntry, Walk};
15use rayon::prelude::*;
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18use std::fmt::Write as FmtWrite;
19use std::fs;
20use std::io::{BufWriter, ErrorKind, Write};
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::SystemTime;
24
25/// Persistence backend for [`SimpleIndexer`].
26pub trait IndexStorage: Send + Sync {
27    /// Prepare any directories or resources required for persistence.
28    fn init(&self, index_dir: &Path) -> Result<()>;
29
30    /// Persist an indexed file entry.
31    fn persist(&self, index_dir: &Path, entry: &FileIndex) -> Result<()>;
32
33    /// Whether this backend expects full-snapshot persistence.
34    ///
35    /// Snapshot-aware backends receive the complete in-memory index on each
36    /// update so on-disk state stays consistent across single-file and
37    /// directory indexing flows.
38    fn prefers_snapshot_persistence(&self) -> bool {
39        false
40    }
41
42    /// Remove a previously persisted file entry.
43    ///
44    /// Defaults to a no-op to keep existing custom storage backends compatible.
45    fn remove(&self, _index_dir: &Path, _file_path: &Path) -> Result<()> {
46        Ok(())
47    }
48
49    /// Persist a batch of indexed file entries.
50    ///
51    /// Defaults to calling [`IndexStorage::persist`] for each entry, keeping
52    /// existing custom storage backends compatible.
53    fn persist_batch(&self, index_dir: &Path, entries: &[FileIndex]) -> Result<()> {
54        for entry in entries {
55            self.persist(index_dir, entry)?;
56        }
57        Ok(())
58    }
59
60    /// Persist a batch of indexed file entries borrowed from the in-memory cache.
61    ///
62    /// Defaults to cloning the borrowed entries and delegating to
63    /// [`IndexStorage::persist_batch`] so existing custom storage backends remain
64    /// compatible.
65    fn persist_batch_refs(&self, index_dir: &Path, entries: &[&FileIndex]) -> Result<()> {
66        let owned = entries.iter().map(|entry| (*entry).clone()).collect::<Vec<_>>();
67        self.persist_batch(index_dir, &owned)
68    }
69}
70
71/// Directory traversal filter hook for [`SimpleIndexer`].
72pub trait TraversalFilter: Send + Sync {
73    /// Determine if the indexer should descend into the provided directory.
74    fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
75
76    /// Determine if the indexer should process the provided file.
77    fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
78}
79
80/// Markdown-backed [`IndexStorage`] implementation.
81#[derive(Debug, Default, Clone)]
82pub struct MarkdownIndexStorage;
83
84impl IndexStorage for MarkdownIndexStorage {
85    fn init(&self, index_dir: &Path) -> Result<()> {
86        fs::create_dir_all(index_dir)?;
87        Ok(())
88    }
89
90    fn persist(&self, index_dir: &Path, entry: &FileIndex) -> Result<()> {
91        fs::create_dir_all(index_dir)?;
92        let file_name = format!("{}.md", calculate_hash(&entry.path));
93        let index_path = index_dir.join(file_name);
94        let file = fs::File::create(index_path)?;
95        let mut writer = BufWriter::new(file);
96        writeln!(writer, "# File Index: {}", entry.path)?;
97        writeln!(writer)?;
98        write_markdown_fields(&mut writer, entry)?;
99        writer.flush()?;
100        Ok(())
101    }
102
103    fn prefers_snapshot_persistence(&self) -> bool {
104        true
105    }
106
107    fn remove(&self, index_dir: &Path, file_path: &Path) -> Result<()> {
108        let file_name = format!("{}.md", calculate_hash(file_path.to_string_lossy().as_ref()));
109        let index_path = index_dir.join(file_name);
110        match fs::remove_file(index_path) {
111            Ok(()) => Ok(()),
112            Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
113            Err(err) => Err(err.into()),
114        }
115    }
116
117    fn persist_batch(&self, index_dir: &Path, entries: &[FileIndex]) -> Result<()> {
118        persist_markdown_snapshot(index_dir, entries.iter())
119    }
120
121    fn persist_batch_refs(&self, index_dir: &Path, entries: &[&FileIndex]) -> Result<()> {
122        persist_markdown_snapshot(index_dir, entries.iter().copied())
123    }
124}
125
126fn persist_markdown_snapshot<'a>(index_dir: &Path, entries: impl IntoIterator<Item = &'a FileIndex>) -> Result<()> {
127    let entries = entries.into_iter().collect::<Vec<_>>();
128
129    fs::create_dir_all(index_dir)?;
130    let temp_path = index_dir.join(".index.md.tmp");
131    let final_path = index_dir.join("index.md");
132    let file = fs::File::create(&temp_path)?;
133    let mut writer = BufWriter::new(file);
134
135    writeln!(writer, "# Workspace File Index")?;
136    writeln!(writer)?;
137    writeln!(writer, "- **Entries**: {}", entries.len())?;
138    writeln!(writer)?;
139
140    for entry in entries {
141        write_markdown_entry(&mut writer, entry)?;
142    }
143
144    writer.flush()?;
145    fs::rename(temp_path, final_path)?;
146    cleanup_legacy_markdown_entries(index_dir)?;
147    Ok(())
148}
149
150/// Default traversal filter powered by [`SimpleIndexerConfig`].
151#[derive(Debug, Default, Clone)]
152pub struct ConfigTraversalFilter;
153
154impl TraversalFilter for ConfigTraversalFilter {
155    fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
156        !should_skip_dir(path, config)
157    }
158
159    fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
160        if !path.is_file() {
161            return false;
162        }
163
164        // Skip hidden files when configured.
165        if config.ignore_hidden && path.file_name().and_then(|n| n.to_str()).is_some_and(|s| s.starts_with('.')) {
166            return false;
167        }
168
169        // Always skip known sensitive files regardless of config.
170        if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
171            && (vtcode_commons::exclusions::is_sensitive_file(file_name)
172                || file_name == ".gitignore"
173                || file_name == ".git")
174        {
175            return false;
176        }
177
178        true
179    }
180}
181
182/// Configuration for [`SimpleIndexer`].
183#[derive(Clone, Debug)]
184pub struct SimpleIndexerConfig {
185    workspace_root: PathBuf,
186    index_dir: PathBuf,
187    ignore_hidden: bool,
188    excluded_dirs: Vec<PathBuf>,
189    allowed_dirs: Vec<PathBuf>,
190}
191
192impl SimpleIndexerConfig {
193    /// Builds a configuration using VT Code's legacy layout as defaults.
194    pub fn new(workspace_root: PathBuf) -> Self {
195        let index_dir = workspace_root.join(".vtcode").join("index");
196        let vtcode_dir = workspace_root.join(".vtcode");
197        let external_dir = vtcode_dir.join("external");
198
199        let mut excluded_dirs: Vec<PathBuf> = vtcode_commons::exclusions::DEFAULT_EXCLUDED_DIRS
200            .iter()
201            .map(|name| workspace_root.join(name))
202            .collect();
203        excluded_dirs.push(index_dir.clone());
204        excluded_dirs.push(vtcode_dir);
205
206        excluded_dirs.dedup();
207
208        Self {
209            workspace_root,
210            index_dir,
211            ignore_hidden: true,
212            excluded_dirs,
213            allowed_dirs: vec![external_dir],
214        }
215    }
216
217    /// Updates the index directory used for persisted metadata.
218    pub fn with_index_dir(mut self, index_dir: impl Into<PathBuf>) -> Self {
219        let index_dir = index_dir.into();
220        self.index_dir = index_dir.clone();
221        self.push_unique_excluded(index_dir);
222        self
223    }
224
225    /// Adds an allowed directory that should be indexed even if hidden or inside an excluded parent.
226    pub fn add_allowed_dir(mut self, path: impl Into<PathBuf>) -> Self {
227        let path = path.into();
228        if !self.allowed_dirs.iter().any(|existing| existing == &path) {
229            self.allowed_dirs.push(path);
230        }
231        self
232    }
233
234    /// Adds an additional excluded directory to skip during traversal.
235    pub fn add_excluded_dir(mut self, path: impl Into<PathBuf>) -> Self {
236        let path = path.into();
237        self.push_unique_excluded(path);
238        self
239    }
240
241    /// Toggles whether hidden directories (prefix `.`) are ignored.
242    pub fn ignore_hidden(mut self, ignore_hidden: bool) -> Self {
243        self.ignore_hidden = ignore_hidden;
244        self
245    }
246
247    /// Workspace root accessor.
248    pub fn workspace_root(&self) -> &Path {
249        &self.workspace_root
250    }
251
252    /// Index directory accessor.
253    pub fn index_dir(&self) -> &Path {
254        &self.index_dir
255    }
256
257    fn push_unique_excluded(&mut self, path: PathBuf) {
258        if !self.excluded_dirs.iter().any(|existing| existing == &path) {
259            self.excluded_dirs.push(path);
260        }
261    }
262}
263
264/// Simple file index entry.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct FileIndex {
267    /// File path.
268    pub path: String,
269    /// File content hash for change detection.
270    pub hash: String,
271    /// Last modified timestamp.
272    pub modified: u64,
273    /// File size.
274    pub size: u64,
275    /// Language/extension.
276    pub language: String,
277    /// Simple tags.
278    pub tags: Vec<String>,
279}
280
281/// Simple search result.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct SearchResult {
284    pub file_path: String,
285    pub line_number: usize,
286    pub line_content: String,
287    pub matches: Vec<String>,
288}
289
290/// Simple file indexer.
291pub struct SimpleIndexer {
292    config: SimpleIndexerConfig,
293    index_cache: HashMap<String, FileIndex>,
294    content_cache: HashMap<String, (String, Arc<str>)>,
295    storage: Arc<dyn IndexStorage>,
296    filter: Arc<dyn TraversalFilter>,
297}
298
299impl SimpleIndexer {
300    /// Create a new simple indexer with default VT Code paths.
301    pub fn new(workspace_root: PathBuf) -> Self {
302        Self::with_components(
303            SimpleIndexerConfig::new(workspace_root),
304            Arc::new(MarkdownIndexStorage),
305            Arc::new(ConfigTraversalFilter),
306        )
307    }
308
309    /// Create a simple indexer with the provided configuration.
310    pub fn with_config(config: SimpleIndexerConfig) -> Self {
311        Self::with_components(config, Arc::new(MarkdownIndexStorage), Arc::new(ConfigTraversalFilter))
312    }
313
314    /// Create a new simple indexer using a custom index directory.
315    pub fn with_index_dir(workspace_root: PathBuf, index_dir: PathBuf) -> Self {
316        let config = SimpleIndexerConfig::new(workspace_root).with_index_dir(index_dir);
317        Self::with_config(config)
318    }
319
320    /// Create an indexer with explicit storage and traversal filter implementations.
321    pub fn with_components(
322        config: SimpleIndexerConfig,
323        storage: Arc<dyn IndexStorage>,
324        filter: Arc<dyn TraversalFilter>,
325    ) -> Self {
326        Self {
327            config,
328            index_cache: HashMap::new(),
329            content_cache: HashMap::new(),
330            storage,
331            filter,
332        }
333    }
334
335    /// Replace the storage backend used to persist index entries.
336    pub fn with_storage(self, storage: Arc<dyn IndexStorage>) -> Self {
337        Self { storage, ..self }
338    }
339
340    /// Replace the traversal filter used to decide which files and directories are indexed.
341    pub fn with_filter(self, filter: Arc<dyn TraversalFilter>) -> Self {
342        Self { filter, ..self }
343    }
344
345    /// Initialize the index directory.
346    pub fn init(&self) -> Result<()> {
347        self.storage.init(self.config.index_dir())
348    }
349
350    /// Get the workspace root path.
351    pub fn workspace_root(&self) -> &Path {
352        self.config.workspace_root()
353    }
354
355    /// Get the index directory used for persisted metadata.
356    pub fn index_dir(&self) -> &Path {
357        self.config.index_dir()
358    }
359
360    /// Index a single file.
361    pub fn index_file(&mut self, file_path: &Path) -> Result<()> {
362        let cache_key = file_path.to_string_lossy().into_owned();
363
364        if self.storage.prefers_snapshot_persistence() {
365            let next_entry = if file_path.exists() && self.should_process_file_path(file_path) {
366                self.build_file_index(file_path)?
367            } else {
368                None
369            };
370
371            self.apply_snapshot_file_update(cache_key, next_entry)?;
372            return Ok(());
373        }
374
375        if !file_path.exists() || !self.should_process_file_path(file_path) {
376            self.index_cache.remove(cache_key.as_str());
377            self.storage.remove(self.config.index_dir(), file_path)?;
378            return Ok(());
379        }
380
381        if let Some(index) = self.build_file_index(file_path)? {
382            self.storage.persist(self.config.index_dir(), &index)?;
383            self.index_cache.insert(index.path.clone(), index);
384        } else {
385            self.index_cache.remove(cache_key.as_str());
386            self.storage.remove(self.config.index_dir(), file_path)?;
387        }
388
389        Ok(())
390    }
391
392    /// Index all files in directory recursively.
393    /// Respects .gitignore, .ignore, and other ignore files.
394    /// SECURITY: Always skips hidden files and sensitive data (.env, .git, etc.)
395    pub fn index_directory(&mut self, dir_path: &Path) -> Result<()> {
396        let walker = self.build_walker(dir_path);
397
398        let mut entries = Vec::new();
399
400        for entry in walker.filter_map(|e| e.ok()) {
401            let path = entry.path();
402
403            // Only index files, not directories
404            if entry.file_type().is_some_and(|ft| ft.is_file())
405                && let Some(index) = self.build_file_index(path)?
406            {
407                entries.push(index);
408            }
409        }
410
411        if self.storage.prefers_snapshot_persistence() {
412            self.apply_snapshot_directory_update(dir_path, &entries)?;
413        } else {
414            entries.sort_unstable_by(|left, right| left.path.cmp(&right.path));
415            self.storage.persist_batch(self.config.index_dir(), &entries)?;
416        }
417
418        self.replace_cached_entries(dir_path, &entries);
419
420        Ok(())
421    }
422
423    /// Discover all files in directory recursively without indexing them.
424    /// This is much faster than `index_directory` as it avoids hashing and persistence.
425    pub fn discover_files(&self, dir_path: &Path) -> Vec<String> {
426        let walker = self.build_walker(dir_path);
427
428        let mut files = walker
429            .filter_map(|e| e.ok())
430            .filter(|e| {
431                if !e.file_type().is_some_and(|ft| ft.is_file()) {
432                    return false;
433                }
434
435                self.should_process_file_path(e.path())
436            })
437            .map(|e| e.path().to_string_lossy().into_owned())
438            .collect::<Vec<_>>();
439        files.sort_unstable();
440        files
441    }
442
443    /// List the immediate children of `dir_path` (one level deep) without
444    /// recursing into subdirectories.
445    ///
446    /// Uses the same ignore/hidden/excluded-directory rules as [`Self::discover_files`]
447    /// (via the shared traversal filter), so expensive subtrees such as
448    /// `node_modules`, `.git`, and build directories are never listed. This lets
449    /// callers build a directory navigator that only touches the directories the
450    /// user actually opens, instead of walking the entire workspace up front.
451    ///
452    /// Returns `(path, is_dir)` pairs with directories sorted before files.
453    pub fn discover_dir_entries(&self, dir_path: &Path) -> Vec<(PathBuf, bool)> {
454        let walker = self.build_shallow_walker(dir_path);
455
456        let mut entries: Vec<(PathBuf, bool)> = walker
457            .filter_map(|e| e.ok())
458            .filter(|e| e.path() != dir_path)
459            .map(|e| {
460                let path = e.path().to_path_buf();
461                let is_dir = e.file_type().is_some_and(|ft| ft.is_dir());
462                (path, is_dir)
463            })
464            .filter(|(path, is_dir)| {
465                if *is_dir {
466                    !should_skip_dir(path, &self.config)
467                } else {
468                    self.should_process_file_path(path)
469                }
470            })
471            .collect();
472
473        entries.sort_by(|a, b| {
474            b.1.cmp(&a.1)
475                .then_with(|| a.0.to_string_lossy().to_lowercase().cmp(&b.0.to_string_lossy().to_lowercase()))
476        });
477        entries
478    }
479
480    /// Internal helper for regex-based file content search.
481    /// Used by both `search()` and `grep()` to avoid code duplication.
482    fn search_files_internal(
483        &self,
484        regex: &Regex,
485        path_filter: Option<&str>,
486        extract_matches: bool,
487    ) -> Vec<SearchResult> {
488        const PARALLEL_THRESHOLD: usize = 64;
489
490        let candidate_paths: Vec<&String> = self
491            .index_cache
492            .keys()
493            .filter(|file_path| path_filter.is_none_or(|filter| file_path.contains(filter)))
494            .collect();
495
496        let mut content_lookup: HashMap<&String, Arc<str>> = HashMap::with_capacity(candidate_paths.len());
497        for path in &candidate_paths {
498            if let Some(index) = self.index_cache.get(*path)
499                && let Some((hash, content)) = self.content_cache.get(*path)
500                && hash == &index.hash
501            {
502                content_lookup.insert(*path, content.clone());
503            }
504        }
505
506        let map_file = move |file_path: &&String| -> Vec<SearchResult> {
507            if let Some(cached) = content_lookup.get(file_path) {
508                return Self::search_content(cached.as_ref(), regex, file_path, extract_matches);
509            }
510            let text = match fs::read_to_string(file_path) {
511                Ok(text) => text,
512                Err(_) => return Vec::new(),
513            };
514            Self::search_content(&text, regex, file_path, extract_matches)
515        };
516
517        let mut results: Vec<SearchResult> = if candidate_paths.len() <= PARALLEL_THRESHOLD {
518            candidate_paths.iter().flat_map(map_file).collect()
519        } else {
520            candidate_paths.par_iter().flat_map(map_file).collect()
521        };
522
523        results.sort_unstable_by(|left, right| {
524            left.file_path
525                .cmp(&right.file_path)
526                .then_with(|| left.line_number.cmp(&right.line_number))
527        });
528        results
529    }
530
531    #[inline]
532    fn search_content(content: &str, regex: &Regex, file_path: &&String, extract_matches: bool) -> Vec<SearchResult> {
533        let mut local = Vec::new();
534        for (line_num, line) in content.lines().enumerate() {
535            if regex.is_match(line) {
536                let matches = if extract_matches {
537                    regex.find_iter(line).map(|m| m.as_str().to_string()).collect()
538                } else {
539                    vec![line.to_string()]
540                };
541
542                local.push(SearchResult {
543                    file_path: (*file_path).clone(),
544                    line_number: line_num + 1,
545                    line_content: line.to_string(),
546                    matches,
547                });
548            }
549        }
550        local
551    }
552
553    /// Search files using regex pattern.
554    pub fn search(&self, pattern: &str, path_filter: Option<&str>) -> Result<Vec<SearchResult>> {
555        let regex = Regex::new(pattern)?;
556        Ok(self.search_files_internal(&regex, path_filter, true))
557    }
558
559    /// Find files by name pattern.
560    pub fn find_files(&self, pattern: &str) -> Result<Vec<String>> {
561        let regex = Regex::new(pattern)?;
562        let mut results = Vec::with_capacity(self.index_cache.len());
563
564        for file_path in self.index_cache.keys() {
565            if regex.is_match(file_path) {
566                results.push(file_path.clone());
567            }
568        }
569
570        results.sort_unstable();
571        Ok(results)
572    }
573
574    /// Get all indexed files without pattern matching.
575    /// This is more efficient than using find_files(".*").
576    pub fn all_files(&self) -> Vec<String> {
577        let mut files = self.index_cache.keys().cloned().collect::<Vec<_>>();
578        files.sort_unstable();
579        files
580    }
581
582    /// Get file content with line numbers.
583    pub fn get_file_content(
584        &self,
585        file_path: &str,
586        start_line: Option<usize>,
587        end_line: Option<usize>,
588    ) -> Result<String> {
589        let content = fs::read_to_string(file_path)?;
590        let start = start_line.unwrap_or(1).max(1);
591        let end = end_line.unwrap_or(usize::MAX);
592
593        if start > end {
594            return Ok(String::new());
595        }
596
597        let mut result = String::new();
598        for (line_number, line) in content.lines().enumerate() {
599            let line_number = line_number + 1;
600            if line_number < start {
601                continue;
602            }
603            if line_number > end {
604                break;
605            }
606            writeln!(&mut result, "{line_number}: {line}")?;
607        }
608
609        Ok(result)
610    }
611
612    /// List files in directory (like ls).
613    pub fn list_files(&self, dir_path: &str, show_hidden: bool) -> Result<Vec<String>> {
614        let path = Path::new(dir_path);
615        if !path.exists() {
616            return Ok(vec![]);
617        }
618
619        let mut files = Vec::new();
620
621        for entry in fs::read_dir(path)? {
622            let entry = entry?;
623            let file_name = entry.file_name().to_string_lossy().into_owned();
624
625            if !show_hidden && file_name.starts_with('.') {
626                continue;
627            }
628
629            files.push(file_name);
630        }
631
632        files.sort_unstable();
633        Ok(files)
634    }
635
636    /// Grep-like search (like grep command).
637    pub fn grep(&self, pattern: &str, file_pattern: Option<&str>) -> Result<Vec<SearchResult>> {
638        let regex = Regex::new(pattern)?;
639        Ok(self.search_files_internal(&regex, file_pattern, false))
640    }
641
642    fn is_allowed_path(&self, path: &Path) -> bool {
643        self.config.allowed_dirs.iter().any(|allowed| path.starts_with(allowed))
644    }
645
646    #[inline]
647    fn get_modified_time(&self, file_path: &Path) -> Result<u64> {
648        let metadata = fs::metadata(file_path)?;
649        let modified = metadata.modified()?;
650        Ok(modified.duration_since(SystemTime::UNIX_EPOCH)?.as_secs())
651    }
652
653    #[inline]
654    fn detect_language(&self, file_path: &Path) -> String {
655        file_path
656            .extension()
657            .and_then(|ext| ext.to_str())
658            .unwrap_or("unknown")
659            .to_string()
660    }
661
662    fn build_file_index(&mut self, file_path: &Path) -> Result<Option<FileIndex>> {
663        if !self.should_process_file_path(file_path) {
664            return Ok(None);
665        }
666
667        let content = match fs::read_to_string(file_path) {
668            Ok(text) => text,
669            Err(err) => {
670                if err.kind() == ErrorKind::InvalidData {
671                    return Ok(None);
672                }
673                return Err(err.into());
674            }
675        };
676
677        let index = FileIndex {
678            path: file_path.to_string_lossy().into_owned(),
679            hash: calculate_hash(&content),
680            modified: self.get_modified_time(file_path)?,
681            size: content.len() as u64,
682            language: self.detect_language(file_path),
683            tags: vec![],
684        };
685
686        self.content_cache
687            .insert(index.path.clone(), (index.hash.clone(), Arc::from(content)));
688
689        Ok(Some(index))
690    }
691
692    #[inline]
693    fn is_excluded_path(&self, path: &Path) -> bool {
694        self.config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded))
695    }
696
697    #[inline]
698    fn should_index_file_path(&self, path: &Path) -> bool {
699        self.filter.should_index_file(path, &self.config)
700    }
701
702    #[inline]
703    fn should_process_file_path(&self, path: &Path) -> bool {
704        if self.is_allowed_path(path) {
705            return self.should_index_file_path(path);
706        }
707
708        !self.is_excluded_path(path) && self.should_index_file_path(path)
709    }
710
711    fn build_walker(&self, dir_path: &Path) -> Walk {
712        let walk_root = dir_path.to_path_buf();
713        let config = self.config.clone();
714        let filter = Arc::clone(&self.filter);
715
716        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
717        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
718        builder.build()
719    }
720
721    fn build_shallow_walker(&self, dir_path: &Path) -> Walk {
722        let walk_root = dir_path.to_path_buf();
723        let config = self.config.clone();
724        let filter = Arc::clone(&self.filter);
725
726        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
727        // Only immediate children — directory navigation lists one level at a time.
728        builder.max_depth(Some(1));
729        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
730        builder.build()
731    }
732
733    fn replace_cached_entries(&mut self, dir_path: &Path, entries: &[FileIndex]) {
734        self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
735        self.content_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
736
737        self.index_cache
738            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
739    }
740
741    fn apply_snapshot_file_update(&mut self, cache_key: String, next_entry: Option<FileIndex>) -> Result<()> {
742        let previous_entry = match next_entry {
743            Some(entry) => self.index_cache.insert(cache_key.clone(), entry),
744            None => self.index_cache.remove(cache_key.as_str()),
745        };
746        let previous_content = self.content_cache.remove(cache_key.as_str());
747
748        if let Err(err) = self.persist_current_snapshot() {
749            match previous_entry {
750                Some(entry) => {
751                    self.index_cache.insert(cache_key.clone(), entry);
752                }
753                None => {
754                    self.index_cache.remove(cache_key.as_str());
755                }
756            }
757            if let Some(content) = previous_content {
758                self.content_cache.insert(cache_key, content);
759            }
760            return Err(err);
761        }
762
763        Ok(())
764    }
765
766    fn apply_snapshot_directory_update(&mut self, dir_path: &Path, entries: &[FileIndex]) -> Result<()> {
767        let previous_entries = self.take_cached_entries(dir_path);
768        self.index_cache
769            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
770
771        if let Err(err) = self.persist_current_snapshot() {
772            self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
773            self.index_cache
774                .extend(previous_entries.into_iter().map(|entry| (entry.path.clone(), entry)));
775            return Err(err);
776        }
777
778        Ok(())
779    }
780
781    fn take_cached_entries(&mut self, dir_path: &Path) -> Vec<FileIndex> {
782        let keys = self
783            .index_cache
784            .keys()
785            .filter(|path| Path::new(path).starts_with(dir_path))
786            .cloned()
787            .collect::<Vec<_>>();
788
789        keys.into_iter()
790            .filter_map(|path| self.index_cache.remove(path.as_str()))
791            .collect()
792    }
793
794    fn persist_current_snapshot(&self) -> Result<()> {
795        let mut snapshot = self.index_cache.values().collect::<Vec<_>>();
796        snapshot.sort_unstable_by(|left, right| left.path.cmp(&right.path));
797        self.storage.persist_batch_refs(self.config.index_dir(), &snapshot)
798    }
799}
800
801impl Clone for SimpleIndexer {
802    fn clone(&self) -> Self {
803        Self {
804            config: self.config.clone(),
805            index_cache: self.index_cache.clone(),
806            content_cache: self.content_cache.clone(),
807            storage: self.storage.clone(),
808            filter: self.filter.clone(),
809        }
810    }
811}
812
813fn should_skip_dir(path: &Path, config: &SimpleIndexerConfig) -> bool {
814    if is_allowed_path_or_ancestor(path, config) {
815        return false;
816    }
817
818    if config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded)) {
819        return true;
820    }
821
822    if config.ignore_hidden
823        && path
824            .file_name()
825            .and_then(|name| name.to_str())
826            .is_some_and(|name_str| name_str.starts_with('.'))
827    {
828        return true;
829    }
830
831    false
832}
833
834fn is_allowed_path_or_ancestor(path: &Path, config: &SimpleIndexerConfig) -> bool {
835    config
836        .allowed_dirs
837        .iter()
838        .any(|allowed| path.starts_with(allowed) || allowed.starts_with(path))
839}
840
841fn should_visit_entry(
842    entry: &DirEntry,
843    walk_root: &Path,
844    config: &SimpleIndexerConfig,
845    filter: &dyn TraversalFilter,
846) -> bool {
847    if entry.path() == walk_root {
848        return true;
849    }
850
851    if !entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
852        return true;
853    }
854
855    filter.should_descend(entry.path(), config)
856}
857
858#[inline]
859fn calculate_hash(content: &str) -> String {
860    vtcode_commons::utils::calculate_sha256(content.as_bytes())
861}
862
863fn write_markdown_entry(writer: &mut impl Write, entry: &FileIndex) -> std::io::Result<()> {
864    writeln!(writer, "## {}", entry.path)?;
865    writeln!(writer)?;
866    write_markdown_fields(writer, entry)?;
867    writeln!(writer)?;
868    Ok(())
869}
870
871fn write_markdown_fields(writer: &mut impl Write, entry: &FileIndex) -> std::io::Result<()> {
872    writeln!(writer, "- **Path**: {}", entry.path)?;
873    writeln!(writer, "- **Hash**: {}", entry.hash)?;
874    writeln!(writer, "- **Modified**: {}", entry.modified)?;
875    writeln!(writer, "- **Size**: {} bytes", entry.size)?;
876    writeln!(writer, "- **Language**: {}", entry.language)?;
877    writeln!(writer, "- **Tags**: {}", entry.tags.join(", "))?;
878    Ok(())
879}
880
881fn cleanup_legacy_markdown_entries(index_dir: &Path) -> Result<()> {
882    for entry in fs::read_dir(index_dir)? {
883        let entry = entry?;
884        let file_name = entry.file_name();
885        let file_name = file_name.to_string_lossy();
886        if is_legacy_markdown_entry_name(file_name.as_ref()) {
887            fs::remove_file(entry.path())?;
888        }
889    }
890    Ok(())
891}
892
893#[inline]
894fn is_legacy_markdown_entry_name(file_name: &str) -> bool {
895    let Some(hash_part) = file_name.strip_suffix(".md") else {
896        return false;
897    };
898    hash_part.len() == 64 && hash_part.bytes().all(|byte| byte.is_ascii_hexdigit())
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use std::fs;
905    use std::sync::{Arc, Mutex};
906    use tempfile::tempdir;
907
908    #[test]
909    fn skips_hidden_directories_by_default() -> Result<()> {
910        let temp = tempdir()?;
911        let workspace = temp.path();
912        let hidden_dir = workspace.join(".private");
913        fs::create_dir_all(&hidden_dir)?;
914        fs::write(hidden_dir.join("secret.txt"), "classified")?;
915
916        let visible_dir = workspace.join("src");
917        fs::create_dir_all(&visible_dir)?;
918        fs::write(visible_dir.join("lib.rs"), "fn main() {}")?;
919
920        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
921        indexer.init()?;
922        indexer.index_directory(workspace)?;
923
924        assert!(indexer.find_files("secret\\.txt$")?.is_empty());
925        assert!(!indexer.find_files("lib\\.rs$")?.is_empty());
926
927        Ok(())
928    }
929
930    #[test]
931    fn can_include_hidden_directories_when_configured() -> Result<()> {
932        let temp = tempdir()?;
933        let workspace = temp.path();
934        let hidden_dir = workspace.join(".cache");
935        fs::create_dir_all(&hidden_dir)?;
936        fs::write(hidden_dir.join("data.log"), "details")?;
937
938        let config = SimpleIndexerConfig::new(workspace.to_path_buf()).ignore_hidden(false);
939        let mut indexer = SimpleIndexer::with_config(config);
940        indexer.init()?;
941        indexer.index_directory(workspace)?;
942
943        let results = indexer.find_files("data\\.log$")?;
944        assert_eq!(results.len(), 1);
945
946        Ok(())
947    }
948
949    #[test]
950    fn indexes_allowed_directories_inside_hidden_excluded_parents() -> Result<()> {
951        let temp = tempdir()?;
952        let workspace = temp.path();
953        let allowed_dir = workspace.join(".vtcode").join("external");
954        fs::create_dir_all(&allowed_dir)?;
955        fs::write(allowed_dir.join("plugin.toml"), "name = 'demo'")?;
956
957        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
958        indexer.init()?;
959        indexer.index_directory(workspace)?;
960
961        let results = indexer.find_files("plugin\\.toml$")?;
962        assert_eq!(results.len(), 1);
963
964        Ok(())
965    }
966
967    #[test]
968    fn reindexing_prunes_deleted_files_from_cache() -> Result<()> {
969        let temp = tempdir()?;
970        let workspace = temp.path();
971        let file_path = workspace.join("notes.txt");
972        fs::write(&file_path, "remember this")?;
973
974        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
975        indexer.init()?;
976        indexer.index_directory(workspace)?;
977        assert_eq!(indexer.find_files("notes\\.txt$")?.len(), 1);
978
979        fs::remove_file(&file_path)?;
980        indexer.index_directory(workspace)?;
981
982        assert!(indexer.find_files("notes\\.txt$")?.is_empty());
983        assert!(indexer.all_files().is_empty());
984
985        Ok(())
986    }
987
988    #[test]
989    fn index_file_skips_excluded_paths() -> Result<()> {
990        let temp = tempdir()?;
991        let workspace = temp.path();
992        let index_dir = workspace.join(".vtcode").join("index");
993        fs::create_dir_all(&index_dir)?;
994        let generated_index = index_dir.join("index.md");
995        fs::write(&generated_index, "# generated")?;
996
997        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
998        indexer.init()?;
999        indexer.index_file(&generated_index)?;
1000
1001        assert!(indexer.all_files().is_empty());
1002
1003        Ok(())
1004    }
1005
1006    #[test]
1007    fn index_file_removes_stale_entry_when_file_becomes_unreadable() -> Result<()> {
1008        let temp = tempdir()?;
1009        let workspace = temp.path();
1010        let file_path = workspace.join("notes.txt");
1011        fs::write(&file_path, "remember this")?;
1012
1013        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1014        indexer.init()?;
1015        indexer.index_file(&file_path)?;
1016        assert!(
1017            indexer
1018                .find_files("notes\\.txt$")?
1019                .iter()
1020                .any(|file| file.ends_with("notes.txt"))
1021        );
1022
1023        fs::write(&file_path, [0xFF, 0xFE, 0xFD])?;
1024        indexer.index_file(&file_path)?;
1025
1026        assert!(indexer.find_files("notes\\.txt$")?.is_empty());
1027
1028        let index_content = fs::read_to_string(workspace.join(".vtcode").join("index").join("index.md"))?;
1029        assert!(!index_content.contains(file_path.to_string_lossy().as_ref()));
1030
1031        Ok(())
1032    }
1033
1034    #[test]
1035    fn index_file_maintains_markdown_snapshot_across_updates() -> Result<()> {
1036        let temp = tempdir()?;
1037        let workspace = temp.path();
1038        let first = workspace.join("first.txt");
1039        let second = workspace.join("second.txt");
1040        fs::write(&first, "one")?;
1041        fs::write(&second, "two")?;
1042
1043        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1044        indexer.init()?;
1045        indexer.index_file(&first)?;
1046        indexer.index_file(&second)?;
1047
1048        let index_dir = workspace.join(".vtcode").join("index");
1049        let files = fs::read_dir(&index_dir)?
1050            .filter_map(|entry| entry.ok())
1051            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1052            .collect::<Vec<_>>();
1053        assert_eq!(files, vec!["index.md".to_string()]);
1054
1055        let index_content = fs::read_to_string(index_dir.join("index.md"))?;
1056        assert!(index_content.contains(first.to_string_lossy().as_ref()));
1057        assert!(index_content.contains(second.to_string_lossy().as_ref()));
1058
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn index_directory_writes_markdown_snapshot_without_manual_init() -> Result<()> {
1064        let temp = tempdir()?;
1065        let workspace = temp.path();
1066        fs::write(workspace.join("notes.txt"), "remember this")?;
1067
1068        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1069        indexer.index_directory(workspace)?;
1070
1071        let index_content = fs::read_to_string(workspace.join(".vtcode").join("index").join("index.md"))?;
1072        assert!(index_content.contains(workspace.join("notes.txt").to_string_lossy().as_ref()));
1073
1074        Ok(())
1075    }
1076
1077    #[test]
1078    fn get_file_content_clamps_ranges_without_panicking() -> Result<()> {
1079        let temp = tempdir()?;
1080        let workspace = temp.path();
1081        let file_path = workspace.join("notes.txt");
1082        fs::write(&file_path, "first\nsecond")?;
1083
1084        let indexer = SimpleIndexer::new(workspace.to_path_buf());
1085        let file_path = file_path.to_string_lossy().into_owned();
1086
1087        assert_eq!(indexer.get_file_content(&file_path, Some(5), None)?, "");
1088        assert_eq!(indexer.get_file_content(&file_path, Some(0), Some(1))?, "1: first\n");
1089        assert_eq!(indexer.get_file_content(&file_path, Some(2), Some(1))?, "");
1090
1091        Ok(())
1092    }
1093
1094    #[test]
1095    fn supports_custom_storage_backends() -> Result<()> {
1096        #[derive(Clone, Default)]
1097        struct MemoryStorage {
1098            records: Arc<Mutex<Vec<FileIndex>>>,
1099        }
1100
1101        impl MemoryStorage {
1102            fn new(records: Arc<Mutex<Vec<FileIndex>>>) -> Self {
1103                Self { records }
1104            }
1105        }
1106
1107        impl IndexStorage for MemoryStorage {
1108            fn init(&self, _index_dir: &Path) -> Result<()> {
1109                Ok(())
1110            }
1111
1112            fn persist(&self, _index_dir: &Path, entry: &FileIndex) -> Result<()> {
1113                let mut guard = self.records.lock().expect("lock poisoned");
1114                guard.push(entry.clone());
1115                Ok(())
1116            }
1117        }
1118
1119        let temp = tempdir()?;
1120        let workspace = temp.path();
1121        fs::write(workspace.join("notes.txt"), "remember this")?;
1122
1123        let records: Arc<Mutex<Vec<FileIndex>>> = Arc::new(Mutex::new(Vec::new()));
1124        let storage = MemoryStorage::new(records.clone());
1125
1126        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1127        let mut indexer = SimpleIndexer::with_config(config).with_storage(Arc::new(storage));
1128        indexer.init()?;
1129        indexer.index_directory(workspace)?;
1130
1131        let entries = records.lock().expect("lock poisoned");
1132        assert_eq!(entries.len(), 1);
1133        assert_eq!(entries[0].path, workspace.join("notes.txt").to_string_lossy().into_owned());
1134
1135        Ok(())
1136    }
1137
1138    #[test]
1139    fn custom_filters_can_skip_files() -> Result<()> {
1140        #[derive(Default)]
1141        struct SkipRustFilter {
1142            inner: ConfigTraversalFilter,
1143        }
1144
1145        impl TraversalFilter for SkipRustFilter {
1146            fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
1147                self.inner.should_descend(path, config)
1148            }
1149
1150            fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
1151                if path
1152                    .extension()
1153                    .and_then(|ext| ext.to_str())
1154                    .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
1155                {
1156                    return false;
1157                }
1158
1159                self.inner.should_index_file(path, config)
1160            }
1161        }
1162
1163        let temp = tempdir()?;
1164        let workspace = temp.path();
1165        fs::write(workspace.join("lib.rs"), "fn main() {}")?;
1166        fs::write(workspace.join("README.md"), "# Notes")?;
1167
1168        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1169        let mut indexer = SimpleIndexer::with_config(config).with_filter(Arc::new(SkipRustFilter::default()));
1170        indexer.init()?;
1171        indexer.index_directory(workspace)?;
1172
1173        assert!(indexer.find_files("lib\\.rs$")?.is_empty());
1174        assert!(!indexer.find_files("README\\.md$")?.is_empty());
1175
1176        Ok(())
1177    }
1178
1179    #[test]
1180    fn custom_filters_can_skip_directories() -> Result<()> {
1181        #[derive(Default)]
1182        struct SkipGeneratedFilter {
1183            inner: ConfigTraversalFilter,
1184        }
1185
1186        impl TraversalFilter for SkipGeneratedFilter {
1187            fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
1188                if path.ends_with("generated") {
1189                    return false;
1190                }
1191
1192                self.inner.should_descend(path, config)
1193            }
1194
1195            fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
1196                self.inner.should_index_file(path, config)
1197            }
1198        }
1199
1200        let temp = tempdir()?;
1201        let workspace = temp.path();
1202        let generated_dir = workspace.join("generated");
1203        fs::create_dir_all(&generated_dir)?;
1204        fs::write(generated_dir.join("skip.txt"), "ignore me")?;
1205        fs::write(workspace.join("README.md"), "# Notes")?;
1206
1207        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1208        let indexer = SimpleIndexer::with_config(config).with_filter(Arc::new(SkipGeneratedFilter::default()));
1209        let files = indexer.discover_files(workspace);
1210
1211        assert!(!files.iter().any(|file| file.ends_with("skip.txt")));
1212        assert!(files.iter().any(|file| file.ends_with("README.md")));
1213
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn discover_dir_entries_is_shallow_and_ignore_aware() -> Result<()> {
1219        let temp = tempdir()?;
1220        let workspace = temp.path();
1221        fs::create_dir_all(workspace.join("src"))?;
1222        fs::create_dir_all(workspace.join("node_modules"))?;
1223        fs::create_dir_all(workspace.join(".git"))?;
1224        fs::write(workspace.join("README.md"), "# Notes")?;
1225        fs::write(workspace.join("src").join("lib.rs"), "fn main() {}")?;
1226        fs::write(workspace.join("node_modules").join("dep.js"), "x")?;
1227        fs::write(workspace.join(".git").join("config"), "x")?;
1228
1229        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1230        let indexer = SimpleIndexer::with_config(config);
1231        let entries = indexer.discover_dir_entries(workspace);
1232
1233        let names: Vec<String> = entries
1234            .iter()
1235            .map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
1236            .collect();
1237
1238        assert_eq!(entries.len(), 2, "expected exactly README.md and src, got {names:?}");
1239        assert!(names.contains(&"README.md".to_string()));
1240        assert!(names.contains(&"src".to_string()));
1241
1242        let (_, src_is_dir) = entries.iter().find(|(p, _)| p.ends_with("src")).unwrap();
1243        assert!(*src_is_dir);
1244
1245        Ok(())
1246    }
1247
1248    #[test]
1249    fn indexing_multiple_directories_preserves_existing_cache_entries() -> Result<()> {
1250        let temp = tempdir()?;
1251        let workspace = temp.path();
1252        let src_dir = workspace.join("src");
1253        let docs_dir = workspace.join("docs");
1254        fs::create_dir_all(&src_dir)?;
1255        fs::create_dir_all(&docs_dir)?;
1256        fs::write(src_dir.join("lib.rs"), "fn main() {}")?;
1257        fs::write(docs_dir.join("guide.md"), "# Guide")?;
1258
1259        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1260        indexer.init()?;
1261        indexer.index_directory(&src_dir)?;
1262        indexer.index_directory(&docs_dir)?;
1263
1264        assert!(indexer.find_files("lib\\.rs$")?.iter().any(|file| file.ends_with("lib.rs")));
1265        assert!(indexer.find_files("guide\\.md$")?.iter().any(|file| file.ends_with("guide.md")));
1266
1267        let index_content = fs::read_to_string(workspace.join(".vtcode").join("index").join("index.md"))?;
1268        assert!(index_content.contains(src_dir.join("lib.rs").to_string_lossy().as_ref()));
1269        assert!(index_content.contains(docs_dir.join("guide.md").to_string_lossy().as_ref()));
1270
1271        Ok(())
1272    }
1273
1274    #[test]
1275    fn batch_indexing_writes_single_markdown_file() -> Result<()> {
1276        let temp = tempdir()?;
1277        let workspace = temp.path();
1278        fs::write(workspace.join("lib.rs"), "fn main() {}")?;
1279        fs::write(workspace.join("README.md"), "# Notes")?;
1280
1281        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1282        indexer.init()?;
1283        indexer.index_directory(workspace)?;
1284
1285        let index_dir = workspace.join(".vtcode").join("index");
1286        let files = fs::read_dir(&index_dir)?
1287            .filter_map(|entry| entry.ok())
1288            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1289            .collect::<Vec<_>>();
1290        assert_eq!(files, vec!["index.md".to_string()]);
1291
1292        let index_content = fs::read_to_string(index_dir.join("index.md"))?;
1293        assert!(index_content.contains(workspace.join("lib.rs").to_string_lossy().as_ref()));
1294        assert!(index_content.contains(workspace.join("README.md").to_string_lossy().as_ref()));
1295
1296        Ok(())
1297    }
1298
1299    #[test]
1300    fn batch_indexing_removes_legacy_hashed_entries() -> Result<()> {
1301        let temp = tempdir()?;
1302        let workspace = temp.path();
1303        fs::write(workspace.join("lib.rs"), "fn main() {}")?;
1304
1305        let mut indexer = SimpleIndexer::new(workspace.to_path_buf());
1306        indexer.init()?;
1307
1308        let legacy_file_name = format!("{}.md", calculate_hash("legacy-path"));
1309        let legacy_file_path = workspace.join(".vtcode").join("index").join(&legacy_file_name);
1310        fs::write(&legacy_file_path, "# legacy")?;
1311        assert!(legacy_file_path.exists());
1312
1313        indexer.index_directory(workspace)?;
1314
1315        assert!(!legacy_file_path.exists());
1316        let files = fs::read_dir(workspace.join(".vtcode").join("index"))?
1317            .filter_map(|entry| entry.ok())
1318            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1319            .collect::<Vec<_>>();
1320        assert_eq!(files, vec!["index.md".to_string()]);
1321
1322        Ok(())
1323    }
1324
1325    #[test]
1326    fn snapshot_storage_uses_default_ref_batch_persistence() -> Result<()> {
1327        #[derive(Clone, Default)]
1328        struct SnapshotMemoryStorage {
1329            snapshots: Arc<Mutex<Vec<Vec<FileIndex>>>>,
1330        }
1331
1332        impl SnapshotMemoryStorage {
1333            fn new(snapshots: Arc<Mutex<Vec<Vec<FileIndex>>>>) -> Self {
1334                Self { snapshots }
1335            }
1336        }
1337
1338        impl IndexStorage for SnapshotMemoryStorage {
1339            fn init(&self, _index_dir: &Path) -> Result<()> {
1340                Ok(())
1341            }
1342
1343            fn persist(&self, _index_dir: &Path, _entry: &FileIndex) -> Result<()> {
1344                Ok(())
1345            }
1346
1347            fn prefers_snapshot_persistence(&self) -> bool {
1348                true
1349            }
1350
1351            fn persist_batch(&self, _index_dir: &Path, entries: &[FileIndex]) -> Result<()> {
1352                self.snapshots.lock().expect("lock poisoned").push(entries.to_vec());
1353                Ok(())
1354            }
1355        }
1356
1357        let temp = tempdir()?;
1358        let workspace = temp.path();
1359        let file_path = workspace.join("notes.txt");
1360        fs::write(&file_path, "remember this")?;
1361
1362        let snapshots = Arc::new(Mutex::new(Vec::new()));
1363        let storage = SnapshotMemoryStorage::new(snapshots.clone());
1364
1365        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1366        let mut indexer = SimpleIndexer::with_config(config).with_storage(Arc::new(storage));
1367        indexer.index_file(&file_path)?;
1368
1369        let snapshots = snapshots.lock().expect("lock poisoned");
1370        assert_eq!(snapshots.len(), 1);
1371        assert_eq!(snapshots[0].len(), 1);
1372        assert_eq!(snapshots[0][0].path, workspace.join("notes.txt").to_string_lossy().into_owned());
1373
1374        Ok(())
1375    }
1376
1377    #[test]
1378    fn snapshot_index_file_rolls_back_cache_when_persist_fails() -> Result<()> {
1379        #[derive(Clone, Default)]
1380        struct FlakySnapshotStorage {
1381            persist_count: Arc<Mutex<usize>>,
1382        }
1383
1384        impl IndexStorage for FlakySnapshotStorage {
1385            fn init(&self, _index_dir: &Path) -> Result<()> {
1386                Ok(())
1387            }
1388
1389            fn persist(&self, _index_dir: &Path, _entry: &FileIndex) -> Result<()> {
1390                Ok(())
1391            }
1392
1393            fn prefers_snapshot_persistence(&self) -> bool {
1394                true
1395            }
1396
1397            fn persist_batch(&self, _index_dir: &Path, _entries: &[FileIndex]) -> Result<()> {
1398                let mut count = self.persist_count.lock().expect("lock poisoned");
1399                *count += 1;
1400                if *count == 2 {
1401                    anyhow::bail!("simulated snapshot persistence failure");
1402                }
1403                Ok(())
1404            }
1405        }
1406
1407        let temp = tempdir()?;
1408        let workspace = temp.path();
1409        let first = workspace.join("first.txt");
1410        let second = workspace.join("second.txt");
1411        fs::write(&first, "one")?;
1412        fs::write(&second, "two")?;
1413
1414        let config = SimpleIndexerConfig::new(workspace.to_path_buf());
1415        let storage = Arc::new(FlakySnapshotStorage::default());
1416        let mut indexer = SimpleIndexer::with_config(config).with_storage(storage);
1417
1418        indexer.index_file(&first)?;
1419        assert!(
1420            indexer
1421                .find_files("first\\.txt$")?
1422                .iter()
1423                .any(|path| path.ends_with("first.txt"))
1424        );
1425
1426        let err = indexer.index_file(&second).expect_err("second persist should fail");
1427        assert!(err.to_string().contains("simulated snapshot persistence failure"));
1428        assert!(
1429            indexer
1430                .find_files("first\\.txt$")?
1431                .iter()
1432                .any(|path| path.ends_with("first.txt"))
1433        );
1434        assert!(indexer.find_files("second\\.txt$")?.is_empty());
1435
1436        Ok(())
1437    }
1438}