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
12pub 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
41pub trait TraversalFilter: Send + Sync {
43 fn should_descend(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
45
46 fn should_index_file(&self, path: &Path, config: &SimpleIndexerConfig) -> bool;
48
49 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#[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#[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 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 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 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 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 fn ignore_hidden(mut self, ignore_hidden: bool) -> Self {
150 self.ignore_hidden = ignore_hidden;
151 self
152 }
153
154 pub fn workspace_root(&self) -> &Path {
156 &self.workspace_root
157 }
158
159 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#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct FileIndex {
174 path: String,
176 hash: String,
178 modified: u64,
180 size: u64,
182 language: String,
184 tags: Vec<String>,
186}
187
188impl FileIndex {
189 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#[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
212pub 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 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 fn with_config(config: SimpleIndexerConfig) -> Self {
233 Self::with_components(config, Arc::new(MarkdownIndexStorage), Arc::new(ConfigTraversalFilter))
234 }
235
236 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 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 fn with_storage(self, storage: Arc<dyn IndexStorage>) -> Self {
259 Self { storage, ..self }
260 }
261
262 fn with_filter(self, filter: Arc<dyn TraversalFilter>) -> Self {
264 Self { filter, ..self }
265 }
266
267 pub fn init(&self) -> Result<()> {
269 self.storage.init(self.config.index_dir())
270 }
271
272 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 pub fn workspace_root(&self) -> &Path {
300 self.config.workspace_root()
301 }
302
303 pub fn index_dir(&self) -> &Path {
305 self.config.index_dir()
306 }
307
308 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 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 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 fn cached_or_built_index(&mut self, file_path: &Path, rebuilt_any: &mut bool) -> Result<Option<FileIndex>> {
386 let metadata = match fs::metadata(file_path) {
390 Ok(metadata) => metadata,
391 Err(_) => {
392 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 #[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 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 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 (std::cmp::Reverse(*is_dir), path.to_string_lossy().to_lowercase())
504 });
505 entries
506 }
507
508 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 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(®ex, path_filter, true))
586 }
587
588 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 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 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 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 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(®ex, 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 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 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 #[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 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 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#[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;