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