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