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