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