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