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, HashSet};
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().into_owned();
402        if let Some(cached) = self.index_cache.get(cache_key.as_str())
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    fn cached_subdirectory_differs(&self, dir_path: &Path, entries: &[FileIndex]) -> bool {
421        let walked = entries.iter().map(|entry| entry.path.as_str()).collect::<HashSet<_>>();
422        self.index_cache
423            .keys()
424            .filter(|path| Path::new(path).starts_with(dir_path))
425            .map(String::as_str)
426            .collect::<HashSet<_>>()
427            != walked
428    }
429
430    /// Discover all files in directory recursively without indexing them.
431    /// This is much faster than `index_directory` as it avoids hashing and persistence.
432    pub fn discover_files(&self, dir_path: &Path) -> Vec<String> {
433        let walker = self.build_walker(dir_path);
434
435        let mut files = walker
436            .filter_map(|e| e.ok())
437            .filter(|e| {
438                if !e.file_type().is_some_and(|ft| ft.is_file()) {
439                    return false;
440                }
441
442                self.should_process_file_path(e.path())
443            })
444            .map(|e| e.path().to_string_lossy().into_owned())
445            .collect::<Vec<_>>();
446        files.sort_unstable();
447        files
448    }
449
450    /// List the immediate children of `dir_path` (one level deep) without
451    /// recursing into subdirectories.
452    ///
453    /// Uses the same ignore/hidden/excluded-directory rules as [`Self::discover_files`]
454    /// (via the shared traversal filter), so expensive subtrees such as
455    /// `node_modules`, `.git`, and build directories are never listed. This lets
456    /// callers build a directory navigator that only touches the directories the
457    /// user actually opens, instead of walking the entire workspace up front.
458    ///
459    /// Returns `(path, is_dir)` pairs with directories sorted before files.
460    pub fn discover_dir_entries(&self, dir_path: &Path) -> Vec<(PathBuf, bool)> {
461        let walker = self.build_shallow_walker(dir_path);
462
463        let mut entries: Vec<(PathBuf, bool)> = walker
464            .filter_map(|e| e.ok())
465            .filter(|e| e.path() != dir_path)
466            .map(|e| {
467                let path = e.path().to_path_buf();
468                let is_dir = e.file_type().is_some_and(|ft| ft.is_dir());
469                (path, is_dir)
470            })
471            .filter(|(path, is_dir)| {
472                if *is_dir {
473                    !should_skip_dir(path, &self.config)
474                } else {
475                    self.should_process_file_path(path)
476                }
477            })
478            .collect();
479
480        entries.sort_by_cached_key(|(path, is_dir)| {
481            // Cache the lowercased path once per entry: a plain `sort_by` would
482            // allocate two temporary strings on every one of the ~N·log N
483            // comparisons, and this runs on the interactive file-palette path.
484            (std::cmp::Reverse(*is_dir), path.to_string_lossy().to_lowercase())
485        });
486        entries
487    }
488
489    /// Internal helper for regex-based file content search.
490    /// Used by both `search()` and `grep()` to avoid code duplication.
491    fn search_files_internal(
492        &self,
493        regex: &Regex,
494        path_filter: Option<&str>,
495        extract_matches: bool,
496    ) -> Vec<SearchResult> {
497        const PARALLEL_THRESHOLD: usize = 64;
498
499        let candidate_paths: Vec<&String> = self
500            .index_cache
501            .keys()
502            .filter(|file_path| path_filter.is_none_or(|filter| file_path.contains(filter)))
503            .collect();
504
505        let mut content_lookup: HashMap<&String, Arc<str>> = HashMap::with_capacity(candidate_paths.len());
506        for path in &candidate_paths {
507            if let Some(index) = self.index_cache.get(*path)
508                && let Some((hash, content)) = self.content_cache.get(*path)
509                && hash == &index.hash
510            {
511                content_lookup.insert(*path, content.clone());
512            }
513        }
514
515        let map_file = move |file_path: &&String| -> Vec<SearchResult> {
516            if let Some(cached) = content_lookup.get(file_path) {
517                return Self::search_content(cached.as_ref(), regex, file_path, extract_matches);
518            }
519            let text = match fs::read_to_string(file_path) {
520                Ok(text) => text,
521                Err(_) => return Vec::new(),
522            };
523            Self::search_content(&text, regex, file_path, extract_matches)
524        };
525
526        let mut results: Vec<SearchResult> = if candidate_paths.len() <= PARALLEL_THRESHOLD {
527            candidate_paths.iter().flat_map(map_file).collect()
528        } else {
529            candidate_paths.par_iter().flat_map(map_file).collect()
530        };
531
532        results.sort_unstable_by(|left, right| {
533            left.file_path
534                .cmp(&right.file_path)
535                .then_with(|| left.line_number.cmp(&right.line_number))
536        });
537        results
538    }
539
540    #[inline]
541    fn search_content(content: &str, regex: &Regex, file_path: &&String, extract_matches: bool) -> Vec<SearchResult> {
542        let mut local = Vec::new();
543        for (line_num, line) in content.lines().enumerate() {
544            if regex.is_match(line) {
545                let line_content = line.to_string();
546                let matches = if extract_matches {
547                    regex.find_iter(line).map(|m| m.as_str().to_string()).collect()
548                } else {
549                    vec![line_content.clone()]
550                };
551
552                local.push(SearchResult {
553                    file_path: (*file_path).clone(),
554                    line_number: line_num + 1,
555                    line_content,
556                    matches,
557                });
558            }
559        }
560        local
561    }
562
563    /// Search files using regex pattern.
564    pub fn search(&self, pattern: &str, path_filter: Option<&str>) -> Result<Vec<SearchResult>> {
565        let regex = Regex::new(pattern)?;
566        Ok(self.search_files_internal(&regex, path_filter, true))
567    }
568
569    /// Find files by name pattern.
570    fn find_files(&self, pattern: &str) -> Result<Vec<String>> {
571        let regex = Regex::new(pattern)?;
572        let mut results = Vec::with_capacity(self.index_cache.len());
573
574        for file_path in self.index_cache.keys() {
575            if regex.is_match(file_path) {
576                results.push(file_path.clone());
577            }
578        }
579
580        results.sort_unstable();
581        Ok(results)
582    }
583
584    /// Get all indexed files without pattern matching.
585    /// This is more efficient than using find_files(".*").
586    fn all_files(&self) -> Vec<String> {
587        let mut files = self.index_cache.keys().cloned().collect::<Vec<_>>();
588        files.sort_unstable();
589        files
590    }
591
592    /// Get file content with line numbers.
593    fn get_file_content(&self, file_path: &str, start_line: Option<usize>, end_line: Option<usize>) -> Result<String> {
594        let content = fs::read_to_string(file_path)?;
595        let start = start_line.unwrap_or(1).max(1);
596        let end = end_line.unwrap_or(usize::MAX);
597
598        if start > end {
599            return Ok(String::new());
600        }
601
602        let mut result = String::with_capacity(content.len());
603        for (line_number, line) in content.lines().enumerate() {
604            let line_number = line_number + 1;
605            if line_number < start {
606                continue;
607            }
608            if line_number > end {
609                break;
610            }
611            writeln!(&mut result, "{line_number}: {line}")?;
612        }
613
614        Ok(result)
615    }
616
617    /// List files in directory (like ls).
618    pub fn list_files(&self, dir_path: &str, show_hidden: bool) -> Result<Vec<String>> {
619        let path = Path::new(dir_path);
620        if !path.exists() {
621            return Ok(vec![]);
622        }
623
624        let mut files = Vec::new();
625
626        for entry in fs::read_dir(path)? {
627            let entry = entry?;
628            let file_name = entry.file_name().to_string_lossy().into_owned();
629
630            if !show_hidden && file_name.starts_with('.') {
631                continue;
632            }
633
634            files.push(file_name);
635        }
636
637        files.sort_unstable();
638        Ok(files)
639    }
640
641    /// Grep-like search (like grep command).
642    pub fn grep(&self, pattern: &str, file_pattern: Option<&str>) -> Result<Vec<SearchResult>> {
643        let regex = Regex::new(pattern)?;
644        Ok(self.search_files_internal(&regex, file_pattern, false))
645    }
646
647    fn is_allowed_path(&self, path: &Path) -> bool {
648        self.config.allowed_dirs.iter().any(|allowed| path.starts_with(allowed))
649    }
650
651    #[inline]
652    fn get_modified_time(&self, file_path: &Path) -> Result<u64> {
653        metadata_modified_secs(&fs::metadata(file_path)?)
654    }
655
656    #[inline]
657    fn detect_language(&self, file_path: &Path) -> String {
658        file_path
659            .extension()
660            .and_then(|ext| ext.to_str())
661            .unwrap_or("unknown")
662            .to_string()
663    }
664
665    fn build_file_index(&mut self, file_path: &Path) -> Result<Option<FileIndex>> {
666        if !self.should_process_file_path(file_path) {
667            return Ok(None);
668        }
669
670        let content = match fs::read_to_string(file_path) {
671            Ok(text) => text,
672            Err(err) => {
673                if err.kind() == ErrorKind::InvalidData {
674                    return Ok(None);
675                }
676                return Err(err.into());
677            }
678        };
679
680        let modified = self.get_modified_time(file_path)?;
681        Ok(Some(self.finish_file_index(file_path, content, modified)))
682    }
683
684    /// [`Self::build_file_index`] reusing an already-observed `metadata`.
685    ///
686    /// Used by the directory-walk hot loop so each file is stat-ed once.
687    /// A file that vanishes between the stat and the read maps to `None`,
688    /// matching the legacy path's existence re-check. (A custom filter that
689    /// accepts nonexistent paths would have surfaced the read error in the
690    /// legacy path; here the transient delete is skipped instead.)
691    fn build_file_index_with_metadata(
692        &mut self,
693        file_path: &Path,
694        metadata: &fs::Metadata,
695    ) -> Result<Option<FileIndex>> {
696        if !self.should_process_file_path_with_metadata(file_path, metadata) {
697            return Ok(None);
698        }
699
700        let content = match fs::read_to_string(file_path) {
701            Ok(text) => text,
702            Err(err) => {
703                if err.kind() == ErrorKind::InvalidData || err.kind() == ErrorKind::NotFound {
704                    return Ok(None);
705                }
706                return Err(err.into());
707            }
708        };
709
710        let modified = metadata_modified_secs(metadata)?;
711        Ok(Some(self.finish_file_index(file_path, content, modified)))
712    }
713
714    /// Assemble a [`FileIndex`] from already-read content and mtime, and cache
715    /// the content for hash-verified searches.
716    fn finish_file_index(&mut self, file_path: &Path, content: String, modified: u64) -> FileIndex {
717        let index = FileIndex {
718            path: file_path.to_string_lossy().into_owned(),
719            hash: calculate_hash(&content),
720            modified,
721            size: content.len() as u64,
722            language: self.detect_language(file_path),
723            tags: vec![],
724        };
725
726        self.content_cache
727            .insert(index.path.clone(), (index.hash.clone(), Arc::from(content)));
728
729        index
730    }
731
732    #[inline]
733    fn is_excluded_path(&self, path: &Path) -> bool {
734        self.config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded))
735    }
736
737    #[inline]
738    fn should_index_file_path(&self, path: &Path) -> bool {
739        self.filter.should_index_file(path, &self.config)
740    }
741
742    #[inline]
743    fn should_process_file_path(&self, path: &Path) -> bool {
744        self.should_process_with(path, || self.should_index_file_path(path))
745    }
746
747    #[inline]
748    fn should_process_file_path_with_metadata(&self, path: &Path, metadata: &fs::Metadata) -> bool {
749        self.should_process_with(path, || self.filter.should_index_file_with_metadata(path, metadata, &self.config))
750    }
751
752    /// Allowed/excluded scope gate shared by both file filters.
753    ///
754    /// Allowed directories bypass exclusion; everywhere else an excluded path
755    /// short-circuits before the (potentially stat-ing) filter runs.
756    #[inline]
757    fn should_process_with(&self, path: &Path, indexable: impl FnOnce() -> bool) -> bool {
758        if self.is_allowed_path(path) {
759            return indexable();
760        }
761
762        !self.is_excluded_path(path) && indexable()
763    }
764
765    fn build_walker(&self, dir_path: &Path) -> Walk {
766        let walk_root = dir_path.to_path_buf();
767        let config = self.config.clone();
768        let filter = Arc::clone(&self.filter);
769
770        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
771        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
772        builder.build()
773    }
774
775    fn build_shallow_walker(&self, dir_path: &Path) -> Walk {
776        let walk_root = dir_path.to_path_buf();
777        let config = self.config.clone();
778        let filter = Arc::clone(&self.filter);
779
780        let mut builder = vtcode_commons::walk::build_default_walker(dir_path);
781        // Only immediate children — directory navigation lists one level at a time.
782        builder.max_depth(Some(1));
783        builder.filter_entry(move |entry| should_visit_entry(entry, walk_root.as_path(), &config, filter.as_ref()));
784        builder.build()
785    }
786
787    fn replace_cached_entries(&mut self, dir_path: &Path, entries: &[FileIndex]) {
788        self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
789        self.content_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
790
791        self.index_cache
792            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
793    }
794
795    /// Drop content entries under `dir_path` without an index entry (deleted,
796    /// rejected, or unindexable); content for still-indexed files is kept so
797    /// searches avoid a disk read.
798    fn prune_orphaned_content(&mut self, dir_path: &Path) {
799        self.content_cache
800            .retain(|path, _| !Path::new(path).starts_with(dir_path) || self.index_cache.contains_key(path.as_str()));
801    }
802
803    fn apply_snapshot_file_update(&mut self, cache_key: String, next_entry: Option<FileIndex>) -> Result<()> {
804        let previous_entry = match next_entry {
805            Some(entry) => self.index_cache.insert(cache_key.clone(), entry),
806            None => self.index_cache.remove(cache_key.as_str()),
807        };
808        let previous_content = self.content_cache.remove(cache_key.as_str());
809
810        if let Err(err) = self.persist_current_snapshot() {
811            match previous_entry {
812                Some(entry) => {
813                    self.index_cache.insert(cache_key.clone(), entry);
814                }
815                None => {
816                    self.index_cache.remove(cache_key.as_str());
817                }
818            }
819            if let Some(content) = previous_content {
820                self.content_cache.insert(cache_key, content);
821            }
822            return Err(err);
823        }
824
825        Ok(())
826    }
827
828    fn apply_snapshot_directory_update(&mut self, dir_path: &Path, entries: &[FileIndex]) -> Result<()> {
829        let previous_entries = self.take_cached_entries(dir_path);
830        self.index_cache
831            .extend(entries.iter().cloned().map(|entry| (entry.path.clone(), entry)));
832
833        if let Err(err) = self.persist_current_snapshot() {
834            self.index_cache.retain(|path, _| !Path::new(path).starts_with(dir_path));
835            self.index_cache
836                .extend(previous_entries.into_iter().map(|entry| (entry.path.clone(), entry)));
837            return Err(err);
838        }
839
840        Ok(())
841    }
842
843    fn take_cached_entries(&mut self, dir_path: &Path) -> Vec<FileIndex> {
844        let keys = self
845            .index_cache
846            .keys()
847            .filter(|path| Path::new(path).starts_with(dir_path))
848            .cloned()
849            .collect::<Vec<_>>();
850
851        keys.into_iter()
852            .filter_map(|path| self.index_cache.remove(path.as_str()))
853            .collect()
854    }
855
856    fn persist_current_snapshot(&self) -> Result<()> {
857        let mut snapshot = self.index_cache.values().collect::<Vec<_>>();
858        snapshot.sort_unstable_by(|left, right| left.path.cmp(&right.path));
859        self.storage.persist_batch_refs(self.config.index_dir(), &snapshot)
860    }
861}
862
863impl Clone for SimpleIndexer {
864    fn clone(&self) -> Self {
865        Self {
866            config: self.config.clone(),
867            index_cache: self.index_cache.clone(),
868            content_cache: self.content_cache.clone(),
869            storage: self.storage.clone(),
870            filter: self.filter.clone(),
871        }
872    }
873}
874
875fn should_skip_dir(path: &Path, config: &SimpleIndexerConfig) -> bool {
876    if is_allowed_path_or_ancestor(path, config) {
877        return false;
878    }
879
880    if config.excluded_dirs.iter().any(|excluded| path.starts_with(excluded)) {
881        return true;
882    }
883
884    if config.ignore_hidden
885        && path
886            .file_name()
887            .and_then(|name| name.to_str())
888            .is_some_and(|name_str| name_str.starts_with('.'))
889    {
890        return true;
891    }
892
893    false
894}
895
896fn is_allowed_path_or_ancestor(path: &Path, config: &SimpleIndexerConfig) -> bool {
897    config
898        .allowed_dirs
899        .iter()
900        .any(|allowed| path.starts_with(allowed) || allowed.starts_with(path))
901}
902
903fn should_visit_entry(
904    entry: &DirEntry,
905    walk_root: &Path,
906    config: &SimpleIndexerConfig,
907    filter: &dyn TraversalFilter,
908) -> bool {
909    if entry.path() == walk_root {
910        return true;
911    }
912
913    if !entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
914        return true;
915    }
916
917    filter.should_descend(entry.path(), config)
918}
919
920#[inline]
921fn calculate_hash(content: &str) -> String {
922    vtcode_commons::utils::calculate_sha256(content.as_bytes())
923}
924
925/// Second-granularity mtime shared by the index builder and the unchanged
926/// screen so both observe the same clock reading for one stat.
927#[inline]
928fn metadata_modified_secs(metadata: &fs::Metadata) -> Result<u64> {
929    Ok(metadata.modified()?.duration_since(SystemTime::UNIX_EPOCH)?.as_secs())
930}
931
932#[cfg(test)]
933mod tests;