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