Skip to main content

vtcode_indexer/
lib.rs

1#![allow(
2    missing_docs,
3    dead_code,
4    unused_imports,
5    reason = "Intentional compatibility, platform, or test-only suppression."
6)]
7#![expect(
8    unused_results,
9    reason = "Indexer cache and walker updates intentionally use mutation APIs for side effects and discard prior entries."
10)]
11
12//! Workspace-friendly file indexer and file utilities for VT Code.
13//!
14//! `vtcode-indexer` provides:
15//! - A lightweight workspace file indexer with markdown-backed persistence
16//! - Fast parallel fuzzy file search (via `file_search` module)
17//! - Markdown-backed storage utilities (via `markdown_store` module)
18
19pub mod file_search;
20pub mod markdown_store;
21
22mod atomic_write;
23mod snapshot;
24mod storage;
25
26pub use storage::{IndexStorage, MarkdownIndexStorage};
27
28use anyhow::Result;
29use hashbrown::HashMap;
30use ignore::{DirEntry, Walk};
31use rayon::prelude::*;
32use regex::Regex;
33use serde::{Deserialize, Serialize};
34use std::fmt::Write as FmtWrite;
35use std::fs;
36use std::io::ErrorKind;
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::time::SystemTime;
40
41/// Directory traversal filter hook for [`SimpleIndexer`].
42pub trait TraversalFilter: Send + Sync {
43    /// Determine if the indexer should descend into the provided directory.
44    fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
45
46    /// Determine if the indexer should process the provided file.
47    fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
48
49    /// Metadata-aware variant of [`TraversalFilter::should_index_file`].
50    ///
51    /// The default implementation delegates to [`TraversalFilter::should_index_file`],
52    /// so existing filters keep their exact behavior. Filters whose file check
53    /// starts with an existence test should override this to reuse `metadata`
54    /// instead of stat-ing the path a second time.
55    fn should_index_file_with_metadata(
56        &self,
57        path: &Path,
58        metadata: &fs::Metadata,
59        config: &SimpleIndexerConfig,
60    ) -> bool {
61        let _ = metadata;
62        self.should_index_file(path, config)
63    }
64}
65
66/// Default traversal filter powered by [`SimpleIndexerConfig`].
67#[derive(Debug, Default, Clone)]
68pub struct ConfigTraversalFilter;
69
70impl TraversalFilter for ConfigTraversalFilter {
71    fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
72        !should_skip_dir(path, config)
73    }
74
75    fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool {
76        path.is_file() && snapshot::has_indexable_file_name(path, config)
77    }
78
79    fn should_index_file_with_metadata(
80        &self,
81        path: &Path,
82        metadata: &fs::Metadata,
83        config: &SimpleIndexerConfig,
84    ) -> bool {
85        metadata.is_file() && snapshot::has_indexable_file_name(path, config)
86    }
87}
88
89/// Configuration for [`SimpleIndexer`].
90#[derive(Clone, Debug)]
91pub struct SimpleIndexerConfig {
92    workspace_root: PathBuf,
93    index_dir: PathBuf,
94    ignore_hidden: bool,
95    excluded_dirs: Vec<PathBuf>,
96    allowed_dirs: Vec<PathBuf>,
97}
98
99impl SimpleIndexerConfig {
100    /// Builds a configuration using VT Code's legacy layout as defaults.
101    fn new(workspace_root: PathBuf) -> Self {
102        let index_dir = workspace_root.join(".vtcode").join("index");
103        let vtcode_dir = workspace_root.join(".vtcode");
104        let external_dir = vtcode_dir.join("external");
105
106        let mut excluded_dirs: Vec<PathBuf> = vtcode_commons::exclusions::DEFAULT_EXCLUDED_DIRS
107            .iter()
108            .map(|name| workspace_root.join(name))
109            .collect();
110        excluded_dirs.push(index_dir.clone());
111        excluded_dirs.push(vtcode_dir);
112
113        excluded_dirs.dedup();
114
115        Self {
116            workspace_root,
117            index_dir,
118            ignore_hidden: true,
119            excluded_dirs,
120            allowed_dirs: vec![external_dir],
121        }
122    }
123
124    /// Updates the index directory used for persisted metadata.
125    pub fn with_index_dir(mut self, index_dir: impl Into<PathBuf>) -> Self {
126        let index_dir = index_dir.into();
127        self.index_dir = index_dir.clone();
128        self.push_unique_excluded(index_dir);
129        self
130    }
131
132    /// Adds an allowed directory that should be indexed even if hidden or inside an excluded parent.
133    pub fn add_allowed_dir(mut self, path: impl Into<PathBuf>) -> Self {
134        let path = path.into();
135        if !self.allowed_dirs.iter().any(|existing| existing == &path) {
136            self.allowed_dirs.push(path);
137        }
138        self
139    }
140
141    /// Adds an additional excluded directory to skip during traversal.
142    pub fn add_excluded_dir(mut self, path: impl Into<PathBuf>) -> Self {
143        let path = path.into();
144        self.push_unique_excluded(path);
145        self
146    }
147
148    /// Toggles whether hidden directories (prefix `.`) are ignored.
149    fn ignore_hidden(mut self, ignore_hidden: bool) -> Self {
150        self.ignore_hidden = ignore_hidden;
151        self
152    }
153
154    /// Workspace root accessor.
155    pub fn workspace_root(&self) -> &Path {
156        &self.workspace_root
157    }
158
159    /// Index directory accessor.
160    fn index_dir(&self) -> &Path {
161        &self.index_dir
162    }
163
164    fn push_unique_excluded(&mut self, path: PathBuf) {
165        if !self.excluded_dirs.iter().any(|existing| existing == &path) {
166            self.excluded_dirs.push(path);
167        }
168    }
169}
170
171/// Simple file index entry.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct FileIndex {
174    /// File path.
175    path: String,
176    /// File content hash for change detection.
177    hash: String,
178    /// Last modified timestamp.
179    modified: u64,
180    /// File size.
181    size: u64,
182    /// Language/extension.
183    language: String,
184    /// Simple tags.
185    tags: Vec<String>,
186}
187
188impl FileIndex {
189    /// Crate-internal constructor so the snapshot parser (a sibling module)
190    /// can rebuild entries from disk; fields stay private to this module.
191    pub(crate) fn from_parts(
192        path: String,
193        hash: String,
194        modified: u64,
195        size: u64,
196        language: String,
197        tags: Vec<String>,
198    ) -> Self {
199        Self { path, hash, modified, size, language, tags }
200    }
201}
202
203/// Simple search result.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct SearchResult {
206    file_path: String,
207    line_number: usize,
208    line_content: String,
209    matches: Vec<String>,
210}
211
212/// Simple file indexer.
213pub struct SimpleIndexer {
214    config: SimpleIndexerConfig,
215    index_cache: HashMap<String, FileIndex>,
216    content_cache: HashMap<String, (String, Arc<str>)>,
217    storage: Arc<dyn IndexStorage>,
218    filter: Arc<dyn TraversalFilter>,
219}
220
221impl SimpleIndexer {
222    /// Create a new simple indexer with default VT Code paths.
223    pub fn new(workspace_root: PathBuf) -> Self {
224        Self::with_components(
225            SimpleIndexerConfig::new(workspace_root),
226            Arc::new(MarkdownIndexStorage),
227            Arc::new(ConfigTraversalFilter),
228        )
229    }
230
231    /// Create a simple indexer with the provided configuration.
232    fn with_config(config: SimpleIndexerConfig) -> Self {
233        Self::with_components(config, Arc::new(MarkdownIndexStorage), Arc::new(ConfigTraversalFilter))
234    }
235
236    /// Create a new simple indexer using a custom index directory.
237    pub fn with_index_dir(workspace_root: PathBuf, index_dir: PathBuf) -> Self {
238        let config = SimpleIndexerConfig::new(workspace_root).with_index_dir(index_dir);
239        Self::with_config(config)
240    }
241
242    /// Create an indexer with explicit storage and traversal filter implementations.
243    fn with_components(
244        config: SimpleIndexerConfig,
245        storage: Arc<dyn IndexStorage>,
246        filter: Arc<dyn TraversalFilter>,
247    ) -> Self {
248        Self {
249            config,
250            index_cache: HashMap::new(),
251            content_cache: HashMap::new(),
252            storage,
253            filter,
254        }
255    }
256
257    /// Replace the storage backend used to persist index entries.
258    fn with_storage(self, storage: Arc<dyn IndexStorage>) -> Self {
259        Self { storage, ..self }
260    }
261
262    /// Replace the traversal filter used to decide which files and directories are indexed.
263    fn with_filter(self, filter: Arc<dyn TraversalFilter>) -> Self {
264        Self { filter, ..self }
265    }
266
267    /// Initialize the index directory.
268    pub fn init(&self) -> Result<()> {
269        self.storage.init(self.config.index_dir())
270    }
271
272    /// Populate the index cache from the persisted snapshot so re-index runs
273    /// can skip unchanged files (see [`Self::index_directory`]). Entries
274    /// outside the workspace root, in excluded directories, with hidden
275    /// or sensitive names, or with `..` segments are discarded so a tampered
276    /// snapshot can never surface files the indexer would refuse to index.
277    /// Existence is deliberately unchecked: the re-index diff needs deleted
278    /// files' entries to prune them. A missing or malformed snapshot leaves
279    /// the cache empty and never fails. Returns the hydrated count.
280    pub fn hydrate_from_snapshot(&mut self) -> usize {
281        let root = self.config.workspace_root().to_path_buf();
282        let mut hydrated = 0;
283        for entry in snapshot::load_snapshot_entries(self.config.index_dir()) {
284            let path = Path::new(&entry.path);
285            if path.starts_with(&root)
286                && !snapshot::has_parent_reference(path)
287                && (self.is_allowed_path(path) || !self.is_excluded_path(path))
288                && snapshot::has_indexable_file_name(path, &self.config)
289            {
290                self.index_cache.insert(entry.path.clone(), entry);
291                hydrated += 1;
292            }
293        }
294        tracing::debug!(hydrated, total = self.index_cache.len(), "index cache hydrated from snapshot");
295        hydrated
296    }
297
298    /// Get the workspace root path.
299    pub fn workspace_root(&self) -> &Path {
300        self.config.workspace_root()
301    }
302
303    /// Get the index directory used for persisted metadata.
304    pub fn index_dir(&self) -> &Path {
305        self.config.index_dir()
306    }
307
308    /// Index a single file.
309    fn index_file(&mut self, file_path: &Path) -> Result<()> {
310        let cache_key = file_path.to_string_lossy().into_owned();
311
312        if self.storage.prefers_snapshot_persistence() {
313            let next_entry = if file_path.exists() && self.should_process_file_path(file_path) {
314                self.build_file_index(file_path)?
315            } else {
316                None
317            };
318
319            self.apply_snapshot_file_update(cache_key, next_entry)?;
320            return Ok(());
321        }
322
323        if !file_path.exists() || !self.should_process_file_path(file_path) {
324            self.index_cache.remove(cache_key.as_str());
325            self.storage.remove(self.config.index_dir(), file_path)?;
326            return Ok(());
327        }
328
329        if let Some(index) = self.build_file_index(file_path)? {
330            self.storage.persist(self.config.index_dir(), &index)?;
331            self.index_cache.insert(index.path.clone(), index);
332        } else {
333            self.index_cache.remove(cache_key.as_str());
334            self.storage.remove(self.config.index_dir(), file_path)?;
335        }
336
337        Ok(())
338    }
339
340    /// Index all files in directory recursively.
341    /// Respects .gitignore, .ignore, and other ignore files.
342    /// SECURITY: Always skips hidden files and sensitive data (.env, .git, etc.)
343    ///
344    /// Hydrated entries matching size and mtime are served without a read or
345    /// hash (see `cached_or_built_index`); an unchanged directory also skips
346    /// the whole-snapshot rewrite.
347    pub fn index_directory(&mut self, dir_path: &Path) -> Result<()> {
348        let walker = self.build_walker(dir_path);
349
350        let mut entries = Vec::new();
351        let mut rebuilt_any = false;
352
353        for entry in walker.filter_map(|e| e.ok()) {
354            let path = entry.path();
355
356            // Only index files, not directories
357            if entry.file_type().is_some_and(|ft| ft.is_file())
358                && let Some(index) = self.cached_or_built_index(path, &mut rebuilt_any)?
359            {
360                entries.push(index);
361            }
362        }
363
364        if self.storage.prefers_snapshot_persistence() {
365            if rebuilt_any || self.cached_subdirectory_differs(dir_path, &entries) {
366                self.apply_snapshot_directory_update(dir_path, &entries)?;
367            }
368            self.prune_orphaned_content(dir_path);
369            return Ok(());
370        }
371
372        entries.sort_unstable_by(|left, right| left.path.cmp(&right.path));
373        self.storage.persist_batch(self.config.index_dir(), &entries)?;
374        self.replace_cached_entries(dir_path, &entries);
375
376        Ok(())
377    }
378
379    /// Serve a cached [`FileIndex`] when size and mtime still match the
380    /// hydrated entry, avoiding a full read + SHA-256 per unchanged file;
381    /// otherwise build. Sets `rebuilt_any` when a file is built so the caller
382    /// can skip no-op snapshot rewrites (entry-set drops are caught by the
383    /// caller's directory diff). `modified` is second-granularity: a
384    /// same-second, same-size rewrite is picked up by the next run.
385    fn cached_or_built_index(&mut self, file_path: &Path, rebuilt_any: &mut bool) -> Result<Option<FileIndex>> {
386        // Stat once and reuse the metadata for the unchanged screen, the
387        // traversal filter, and the rebuilt entry instead of stat-ing up to
388        // four times per file (filter, screen, filter, mtime).
389        let metadata = match fs::metadata(file_path) {
390            Ok(metadata) => metadata,
391            Err(_) => {
392                // Missing/unreadable: preserve `build_file_index` semantics,
393                // which maps missing files to `None` unless a custom filter
394                // claims the path (in which case the read error propagates).
395                let built = self.build_file_index(file_path)?;
396                *rebuilt_any |= built.is_some();
397                return Ok(built);
398            }
399        };
400
401        let cache_key = file_path.to_string_lossy();
402        if let Some(cached) = self.index_cache.get(cache_key.as_ref())
403            && self.should_process_file_path_with_metadata(file_path, &metadata)
404            && let Ok(modified) = metadata.modified()
405            && let Ok(elapsed) = modified.duration_since(SystemTime::UNIX_EPOCH)
406            && elapsed.as_secs() == cached.modified
407            && metadata.len() == cached.size
408        {
409            return Ok(Some(cached.clone()));
410        }
411
412        let built = self.build_file_index_with_metadata(file_path, &metadata)?;
413        *rebuilt_any |= built.is_some();
414        Ok(built)
415    }
416
417    /// Whether the walked entries differ from the cached entries under
418    /// `dir_path`. Deletions show up here; additions and content changes set
419    /// `rebuilt_any` in [`Self::cached_or_built_index`] instead.
420    ///
421    /// Compares by count plus membership instead of materializing two `HashSet`s.
422    /// Requires: `entries` holds at most one entry per path, as produced by the
423    /// [`Self::index_directory`] walker loop — equal counts plus every walked path
424    /// present in the cache then means the sets match.
425    /// Uniqueness precondition shared by [`Self::cached_subdirectory_differs`].
426    #[cfg(debug_assertions)]
427    fn walked_paths_are_unique(entries: &[FileIndex]) -> bool {
428        let mut seen = std::collections::HashSet::new();
429        entries.iter().all(|entry| seen.insert(entry.path.as_str()))
430    }
431
432    #[must_use]
433    fn cached_subdirectory_differs(&self, dir_path: &Path, entries: &[FileIndex]) -> bool {
434        debug_assert!(
435            Self::walked_paths_are_unique(entries),
436            "cached_subdirectory_differs requires at most one entry per path"
437        );
438        let cached_count = self
439            .index_cache
440            .keys()
441            .filter(|path| Path::new(path).starts_with(dir_path))
442            .count();
443        if cached_count != entries.len() {
444            return true;
445        }
446        entries.iter().any(|entry| !self.index_cache.contains_key(entry.path.as_str()))
447    }
448
449    /// Discover all files in directory recursively without indexing them.
450    /// This is much faster than `index_directory` as it avoids hashing and persistence.
451    pub fn discover_files(&self, dir_path: &Path) -> Vec<String> {
452        let walker = self.build_walker(dir_path);
453
454        let mut files = walker
455            .filter_map(|e| e.ok())
456            .filter(|e| {
457                if !e.file_type().is_some_and(|ft| ft.is_file()) {
458                    return false;
459                }
460
461                self.should_process_file_path(e.path())
462            })
463            .map(|e| e.path().to_string_lossy().into_owned())
464            .collect::<Vec<_>>();
465        files.sort_unstable();
466        files
467    }
468
469    /// List the immediate children of `dir_path` (one level deep) without
470    /// recursing into subdirectories.
471    ///
472    /// Uses the same ignore/hidden/excluded-directory rules as [`Self::discover_files`]
473    /// (via the shared traversal filter), so expensive subtrees such as
474    /// `node_modules`, `.git`, and build directories are never listed. This lets
475    /// callers build a directory navigator that only touches the directories the
476    /// user actually opens, instead of walking the entire workspace up front.
477    ///
478    /// Returns `(path, is_dir)` pairs with directories sorted before files.
479    pub fn discover_dir_entries(&self, dir_path: &Path) -> Vec<(PathBuf, bool)> {
480        let walker = self.build_shallow_walker(dir_path);
481
482        let mut entries: Vec<(PathBuf, bool)> = walker
483            .filter_map(|e| e.ok())
484            .filter(|e| e.path() != dir_path)
485            .map(|e| {
486                let path = e.path().to_path_buf();
487                let is_dir = e.file_type().is_some_and(|ft| ft.is_dir());
488                (path, is_dir)
489            })
490            .filter(|(path, is_dir)| {
491                if *is_dir {
492                    !should_skip_dir(path, &self.config)
493                } else {
494                    self.should_process_file_path(path)
495                }
496            })
497            .collect();
498
499        entries.sort_by_cached_key(|(path, is_dir)| {
500            // Cache the lowercased path once per entry: a plain `sort_by` would
501            // allocate two temporary strings on every one of the ~N·log N
502            // comparisons, and this runs on the interactive file-palette path.
503            (std::cmp::Reverse(*is_dir), path.to_string_lossy().to_lowercase())
504        });
505        entries
506    }
507
508    /// Internal helper for regex-based file content search.
509    /// Used by both `search()` and `grep()` to avoid code duplication.
510    fn search_files_internal(
511        &self,
512        regex: &Regex,
513        path_filter: Option<&str>,
514        extract_matches: bool,
515    ) -> Vec<SearchResult> {
516        const PARALLEL_THRESHOLD: usize = 64;
517
518        let candidate_paths: Vec<&String> = self
519            .index_cache
520            .keys()
521            .filter(|file_path| path_filter.is_none_or(|filter| file_path.contains(filter)))
522            .collect();
523
524        let mut content_lookup: HashMap<&String, Arc<str>> = HashMap::with_capacity(candidate_paths.len());
525        for path in &candidate_paths {
526            if let Some(index) = self.index_cache.get(*path)
527                && let Some((hash, content)) = self.content_cache.get(*path)
528                && hash == &index.hash
529            {
530                content_lookup.insert(*path, content.clone());
531            }
532        }
533
534        let map_file = move |file_path: &&String| -> Vec<SearchResult> {
535            if let Some(cached) = content_lookup.get(file_path) {
536                return Self::search_content(cached.as_ref(), regex, file_path, extract_matches);
537            }
538            let text = match fs::read_to_string(file_path) {
539                Ok(text) => text,
540                Err(_) => return Vec::new(),
541            };
542            Self::search_content(&text, regex, file_path, extract_matches)
543        };
544
545        let mut results: Vec<SearchResult> = if candidate_paths.len() <= PARALLEL_THRESHOLD {
546            candidate_paths.iter().flat_map(map_file).collect()
547        } else {
548            candidate_paths.par_iter().flat_map(map_file).collect()
549        };
550
551        results.sort_unstable_by(|left, right| {
552            left.file_path
553                .cmp(&right.file_path)
554                .then_with(|| left.line_number.cmp(&right.line_number))
555        });
556        results
557    }
558
559    #[inline]
560    fn search_content(content: &str, regex: &Regex, file_path: &&String, extract_matches: bool) -> Vec<SearchResult> {
561        let mut local = Vec::new();
562        for (line_num, line) in content.lines().enumerate() {
563            if regex.is_match(line) {
564                let line_content = line.to_string();
565                let matches = if extract_matches {
566                    regex.find_iter(line).map(|m| m.as_str().to_string()).collect()
567                } else {
568                    vec![line_content.clone()]
569                };
570
571                local.push(SearchResult {
572                    file_path: (*file_path).clone(),
573                    line_number: line_num + 1,
574                    line_content,
575                    matches,
576                });
577            }
578        }
579        local
580    }
581
582    /// Search files using regex pattern.
583    pub fn search(&self, pattern: &str, path_filter: Option<&str>) -> Result<Vec<SearchResult>> {
584        let regex = Regex::new(pattern)?;
585        Ok(self.search_files_internal(&regex, path_filter, true))
586    }
587
588    /// Find files by name pattern.
589    fn find_files(&self, pattern: &str) -> Result<Vec<String>> {
590        let regex = Regex::new(pattern)?;
591        let mut results = Vec::with_capacity(self.index_cache.len());
592
593        for file_path in self.index_cache.keys() {
594            if regex.is_match(file_path) {
595                results.push(file_path.clone());
596            }
597        }
598
599        results.sort_unstable();
600        Ok(results)
601    }
602
603    /// Get all indexed files without pattern matching.
604    /// This is more efficient than using find_files(".*").
605    fn all_files(&self) -> Vec<String> {
606        let mut files = self.index_cache.keys().cloned().collect::<Vec<_>>();
607        files.sort_unstable();
608        files
609    }
610
611    /// Get file content with line numbers.
612    fn get_file_content(&self, file_path: &str, start_line: Option<usize>, end_line: Option<usize>) -> Result<String> {
613        let content = fs::read_to_string(file_path)?;
614        let start = start_line.unwrap_or(1).max(1);
615        let end = end_line.unwrap_or(usize::MAX);
616
617        if start > end {
618            return Ok(String::new());
619        }
620
621        let mut result = String::with_capacity(content.len());
622        for (line_number, line) in content.lines().enumerate() {
623            let line_number = line_number + 1;
624            if line_number < start {
625                continue;
626            }
627            if line_number > end {
628                break;
629            }
630            writeln!(&mut result, "{line_number}: {line}")?;
631        }
632
633        Ok(result)
634    }
635
636    /// List files in directory (like ls).
637    pub fn list_files(&self, dir_path: &str, show_hidden: bool) -> Result<Vec<String>> {
638        let path = Path::new(dir_path);
639        if !path.exists() {
640            return Ok(vec![]);
641        }
642
643        let mut files = Vec::new();
644
645        for entry in fs::read_dir(path)? {
646            let entry = entry?;
647            let file_name = entry.file_name().to_string_lossy().into_owned();
648
649            if !show_hidden && file_name.starts_with('.') {
650                continue;
651            }
652
653            files.push(file_name);
654        }
655
656        files.sort_unstable();
657        Ok(files)
658    }
659
660    /// Grep-like search (like grep command).
661    pub fn grep(&self, pattern: &str, file_pattern: Option<&str>) -> Result<Vec<SearchResult>> {
662        let regex = Regex::new(pattern)?;
663        Ok(self.search_files_internal(&regex, file_pattern, false))
664    }
665
666    fn is_allowed_path(&self, path: &Path) -> bool {
667        self.config.allowed_dirs.iter().any(|allowed| path.starts_with(allowed))
668    }
669
670    #[inline]
671    fn get_modified_time(&self, file_path: &Path) -> Result<u64> {
672        metadata_modified_secs(&fs::metadata(file_path)?)
673    }
674
675    #[inline]
676    fn detect_language(&self, file_path: &Path) -> String {
677        file_path
678            .extension()
679            .and_then(|ext| ext.to_str())
680            .unwrap_or("unknown")
681            .to_string()
682    }
683
684    fn build_file_index(&mut self, file_path: &Path) -> Result<Option<FileIndex>> {
685        if !self.should_process_file_path(file_path) {
686            return Ok(None);
687        }
688
689        let content = match fs::read_to_string(file_path) {
690            Ok(text) => text,
691            Err(err) => {
692                if err.kind() == ErrorKind::InvalidData {
693                    return Ok(None);
694                }
695                return Err(err.into());
696            }
697        };
698
699        let modified = self.get_modified_time(file_path)?;
700        Ok(Some(self.finish_file_index(file_path, content, modified)))
701    }
702
703    /// [`Self::build_file_index`] reusing an already-observed `metadata`.
704    ///
705    /// Used by the directory-walk hot loop so each file is stat-ed once.
706    /// A file that vanishes between the stat and the read maps to `None`,
707    /// matching the legacy path's existence re-check. (A custom filter that
708    /// accepts nonexistent paths would have surfaced the read error in the
709    /// legacy path; here the transient delete is skipped instead.)
710    fn build_file_index_with_metadata(
711        &mut self,
712        file_path: &Path,
713        metadata: &fs::Metadata,
714    ) -> Result<Option<FileIndex>> {
715        if !self.should_process_file_path_with_metadata(file_path, metadata) {
716            return Ok(None);
717        }
718
719        let content = match fs::read_to_string(file_path) {
720            Ok(text) => text,
721            Err(err) => {
722                if err.kind() == ErrorKind::InvalidData || err.kind() == ErrorKind::NotFound {
723                    return Ok(None);
724                }
725                return Err(err.into());
726            }
727        };
728
729        let modified = metadata_modified_secs(metadata)?;
730        Ok(Some(self.finish_file_index(file_path, content, modified)))
731    }
732
733    /// Assemble a [`FileIndex`] from already-read content and mtime, and cache
734    /// the content for hash-verified searches.
735    fn finish_file_index(&mut self, file_path: &Path, content: String, modified: u64) -> FileIndex {
736        let index = FileIndex {
737            path: file_path.to_string_lossy().into_owned(),
738            hash: calculate_hash(&content),
739            modified,
740            size: content.len() as u64,
741            language: self.detect_language(file_path),
742            tags: vec![],
743        };
744
745        self.content_cache
746            .insert(index.path.clone(), (index.hash.clone(), Arc::from(content)));
747
748        index
749    }
750
751    #[inline]
752    fn is_excluded_path(&self, path: &Path) -> bool {
753        self.config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded))
754    }
755
756    #[inline]
757    fn should_index_file_path(&self, path: &Path) -> bool {
758        self.filter.should_index_file(path, &self.config)
759    }
760
761    #[inline]
762    fn should_process_file_path(&self, path: &Path) -> bool {
763        self.should_process_with(path, || self.should_index_file_path(path))
764    }
765
766    #[inline]
767    fn should_process_file_path_with_metadata(&self, path: &Path, metadata: &fs::Metadata) -> bool {
768        self.should_process_with(path, || self.filter.should_index_file_with_metadata(path, metadata, &self.config))
769    }
770
771    /// Allowed/excluded scope gate shared by both file filters.
772    ///
773    /// Allowed directories bypass exclusion; everywhere else an excluded path
774    /// short-circuits before the (potentially stat-ing) filter runs.
775    #[inline]
776    fn should_process_with(&self, path: &Path, indexable: impl FnOnce() -> bool) -> bool {
777        if self.is_allowed_path(path) {
778            return indexable();
779        }
780
781        !self.is_excluded_path(path) && indexable()
782    }
783
784    fn build_walker(&self, dir_path: &Path) -> Walk {
785        let walk_root = dir_path.to_path_buf();
786        let config = self.config.clone();
787        let filter = Arc::clone(&self.filter);
788
789        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
790        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
791        builder.build()
792    }
793
794    fn build_shallow_walker(&self, dir_path: &Path) -> Walk {
795        let walk_root = dir_path.to_path_buf();
796        let config = self.config.clone();
797        let filter = Arc::clone(&self.filter);
798
799        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
800        // Only immediate children — directory navigation lists one level at a time.
801        builder.max_depth(Some(1));
802        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
803        builder.build()
804    }
805
806    fn replace_cached_entries(&mut self, dir_path: &Path, entries: &[FileIndex]) {
807        self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
808        self.content_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
809
810        self.index_cache
811            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
812    }
813
814    /// Drop content entries under `dir_path` without an index entry (deleted,
815    /// rejected, or unindexable); content for still-indexed files is kept so
816    /// searches avoid a disk read.
817    fn prune_orphaned_content(&mut self, dir_path: &Path) {
818        self.content_cache
819            .retain(|path, _| !Path::new(path).starts_with(dir_path) || self.index_cache.contains_key(path.as_str()));
820    }
821
822    fn apply_snapshot_file_update(&mut self, cache_key: String, next_entry: Option<FileIndex>) -> Result<()> {
823        let previous_entry = match next_entry {
824            Some(entry) => self.index_cache.insert(cache_key.clone(), entry),
825            None => self.index_cache.remove(cache_key.as_str()),
826        };
827        let previous_content = self.content_cache.remove(cache_key.as_str());
828
829        if let Err(err) = self.persist_current_snapshot() {
830            match previous_entry {
831                Some(entry) => {
832                    self.index_cache.insert(cache_key.clone(), entry);
833                }
834                None => {
835                    self.index_cache.remove(cache_key.as_str());
836                }
837            }
838            if let Some(content) = previous_content {
839                self.content_cache.insert(cache_key, content);
840            }
841            return Err(err);
842        }
843
844        Ok(())
845    }
846
847    fn apply_snapshot_directory_update(&mut self, dir_path: &Path, entries: &[FileIndex]) -> Result<()> {
848        let previous_entries = self.take_cached_entries(dir_path);
849        self.index_cache
850            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
851
852        if let Err(err) = self.persist_current_snapshot() {
853            self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
854            self.index_cache
855                .extend(previous_entries.into_iter().map(|entry| (entry.path.clone(), entry)));
856            return Err(err);
857        }
858
859        Ok(())
860    }
861
862    fn take_cached_entries(&mut self, dir_path: &Path) -> Vec<FileIndex> {
863        let keys = self
864            .index_cache
865            .keys()
866            .filter(|path| Path::new(path).starts_with(dir_path))
867            .cloned()
868            .collect::<Vec<_>>();
869
870        keys.into_iter()
871            .filter_map(|path| self.index_cache.remove(path.as_str()))
872            .collect()
873    }
874
875    fn persist_current_snapshot(&self) -> Result<()> {
876        let mut snapshot = self.index_cache.values().collect::<Vec<_>>();
877        snapshot.sort_unstable_by(|left, right| left.path.cmp(&right.path));
878        self.storage.persist_batch_refs(self.config.index_dir(), &snapshot)
879    }
880}
881
882impl Clone for SimpleIndexer {
883    fn clone(&self) -> Self {
884        Self {
885            config: self.config.clone(),
886            index_cache: self.index_cache.clone(),
887            content_cache: self.content_cache.clone(),
888            storage: self.storage.clone(),
889            filter: self.filter.clone(),
890        }
891    }
892}
893
894fn should_skip_dir(path: &Path, config: &SimpleIndexerConfig) -> bool {
895    if is_allowed_path_or_ancestor(path, config) {
896        return false;
897    }
898
899    if config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded)) {
900        return true;
901    }
902
903    if config.ignore_hidden
904        && path
905            .file_name()
906            .and_then(|name| name.to_str())
907            .is_some_and(|name_str| name_str.starts_with('.'))
908    {
909        return true;
910    }
911
912    false
913}
914
915fn is_allowed_path_or_ancestor(path: &Path, config: &SimpleIndexerConfig) -> bool {
916    config
917        .allowed_dirs
918        .iter()
919        .any(|allowed| path.starts_with(allowed) || allowed.starts_with(path))
920}
921
922fn should_visit_entry(
923    entry: &DirEntry,
924    walk_root: &Path,
925    config: &SimpleIndexerConfig,
926    filter: &dyn TraversalFilter,
927) -> bool {
928    if entry.path() == walk_root {
929        return true;
930    }
931
932    if !entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
933        return true;
934    }
935
936    filter.should_descend(entry.path(), config)
937}
938
939#[inline]
940fn calculate_hash(content: &str) -> String {
941    vtcode_commons::utils::calculate_sha256(content.as_bytes())
942}
943
944/// Second-granularity mtime shared by the index builder and the unchanged
945/// screen so both observe the same clock reading for one stat.
946#[inline]
947fn metadata_modified_secs(metadata: &fs::Metadata) -> Result<u64> {
948    Ok(metadata.modified()?.duration_since(SystemTime::UNIX_EPOCH)?.as_secs())
949}
950
951#[cfg(test)]
952mod tests;