Skip to main content

vtcode_indexer/
lib.rs

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