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, 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
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().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 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 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 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 (std::cmp::Reverse(*is_dir), path.to_string_lossy().to_lowercase())
485 });
486 entries
487 }
488
489 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 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(®ex, path_filter, true))
567 }
568
569 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 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 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 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 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(®ex, 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 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 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 #[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 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 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#[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;