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