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