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