Skip to main content

vtcode_indexer/
storage.rs

1//! Snapshot persistence for the workspace file index.
2//!
3//! [`MarkdownIndexStorage`] writes the whole index as one human-readable
4//! markdown snapshot (`index.md`) via atomic rename; [`snapshot`](crate::snapshot)
5//! parses that exact format back. Legacy per-file `.md` entries are removed on
6//! every snapshot write.
7
8use anyhow::Result;
9use std::fs;
10use std::io::{ErrorKind, Write};
11use std::path::Path;
12
13use crate::FileIndex;
14use crate::atomic_write;
15use crate::calculate_hash;
16
17/// Persistence backend for [`SimpleIndexer`](crate::SimpleIndexer).
18pub trait IndexStorage: Send + Sync {
19    /// Prepare any directories or resources required for persistence.
20    fn init(&self, index_dir: &Path) -> Result<()>;
21
22    /// Persist an indexed file entry.
23    fn persist(&self, index_dir: &Path, entry: &FileIndex) -> Result<()>;
24
25    /// Whether this backend expects full-snapshot persistence.
26    ///
27    /// Snapshot-aware backends receive the complete in-memory index on each
28    /// update so on-disk state stays consistent across single-file and
29    /// directory indexing flows.
30    fn prefers_snapshot_persistence(&self) -> bool {
31        false
32    }
33
34    /// Remove a previously persisted file entry.
35    ///
36    /// Defaults to a no-op to keep existing custom storage backends compatible.
37    fn remove(&self, _index_dir: &Path, _file_path: &Path) -> Result<()> {
38        Ok(())
39    }
40
41    /// Persist a batch of indexed file entries.
42    ///
43    /// Defaults to calling [`IndexStorage::persist`] for each entry, keeping
44    /// existing custom storage backends compatible.
45    fn persist_batch(&self, index_dir: &Path, entries: &[FileIndex]) -> Result<()> {
46        for entry in entries {
47            self.persist(index_dir, entry)?;
48        }
49        Ok(())
50    }
51
52    /// Persist a batch of indexed file entries borrowed from the in-memory cache.
53    ///
54    /// Defaults to cloning the borrowed entries and delegating to
55    /// [`IndexStorage::persist_batch`] so existing custom storage backends remain
56    /// compatible.
57    fn persist_batch_refs(&self, index_dir: &Path, entries: &[&FileIndex]) -> Result<()> {
58        let owned = entries.iter().map(|entry| (*entry).clone()).collect::<Vec<_>>();
59        self.persist_batch(index_dir, &owned)
60    }
61}
62
63/// Markdown-backed [`IndexStorage`] implementation.
64#[derive(Debug, Default, Clone)]
65pub struct MarkdownIndexStorage;
66
67impl IndexStorage for MarkdownIndexStorage {
68    fn init(&self, index_dir: &Path) -> Result<()> {
69        fs::create_dir_all(index_dir)?;
70        Ok(())
71    }
72
73    fn persist(&self, index_dir: &Path, entry: &FileIndex) -> Result<()> {
74        let dir = atomic_write::canonical_storage_dir(index_dir)?;
75        let file_name = format!("{}.md", calculate_hash(&entry.path));
76        let mut buffer = Vec::new();
77        writeln!(buffer, "# File Index: {}", entry.path)?;
78        writeln!(buffer)?;
79        write_markdown_fields(&mut buffer, entry)?;
80        atomic_write::write_atomic(&dir, &file_name, &buffer)
81    }
82
83    fn prefers_snapshot_persistence(&self) -> bool {
84        true
85    }
86
87    fn remove(&self, index_dir: &Path, file_path: &Path) -> Result<()> {
88        if !index_dir.exists() {
89            return Ok(());
90        }
91        let dir = atomic_write::canonical_storage_dir(index_dir)?;
92        let file_name = format!("{}.md", calculate_hash(file_path.to_string_lossy().as_ref()));
93        match fs::remove_file(dir.join(file_name)) {
94            Ok(()) => Ok(()),
95            Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
96            Err(err) => Err(err.into()),
97        }
98    }
99
100    fn persist_batch(&self, index_dir: &Path, entries: &[FileIndex]) -> Result<()> {
101        persist_markdown_snapshot(index_dir, entries.iter())
102    }
103
104    fn persist_batch_refs(&self, index_dir: &Path, entries: &[&FileIndex]) -> Result<()> {
105        persist_markdown_snapshot(index_dir, entries.iter().copied())
106    }
107}
108
109fn persist_markdown_snapshot<'a>(index_dir: &Path, entries: impl IntoIterator<Item = &'a FileIndex>) -> Result<()> {
110    let entries = entries.into_iter().collect::<Vec<_>>();
111
112    let dir = atomic_write::canonical_storage_dir(index_dir)?;
113    let mut buffer = Vec::new();
114
115    writeln!(buffer, "# Workspace File Index")?;
116    writeln!(buffer)?;
117    writeln!(buffer, "- **Entries**: {}", entries.len())?;
118    writeln!(buffer)?;
119
120    for entry in entries {
121        write_markdown_entry(&mut buffer, entry)?;
122    }
123
124    atomic_write::write_atomic(&dir, "index.md", &buffer)?;
125    cleanup_legacy_markdown_entries(&dir)?;
126    Ok(())
127}
128
129fn write_markdown_entry(writer: &mut impl Write, entry: &FileIndex) -> std::io::Result<()> {
130    writeln!(writer, "## {}", entry.path)?;
131    writeln!(writer)?;
132    write_markdown_fields(writer, entry)?;
133    writeln!(writer)?;
134    Ok(())
135}
136
137fn write_markdown_fields(writer: &mut impl Write, entry: &FileIndex) -> std::io::Result<()> {
138    writeln!(writer, "- **Path**: {}", entry.path)?;
139    writeln!(writer, "- **Hash**: {}", entry.hash)?;
140    writeln!(writer, "- **Modified**: {}", entry.modified)?;
141    writeln!(writer, "- **Size**: {} bytes", entry.size)?;
142    writeln!(writer, "- **Language**: {}", entry.language)?;
143    writeln!(writer, "- **Tags**: {}", entry.tags.join(", "))?;
144    Ok(())
145}
146
147fn cleanup_legacy_markdown_entries(index_dir: &Path) -> Result<()> {
148    for entry in fs::read_dir(index_dir)? {
149        let entry = entry?;
150        let file_name = entry.file_name();
151        let file_name = file_name.to_string_lossy();
152        if is_legacy_markdown_entry_name(file_name.as_ref()) {
153            fs::remove_file(entry.path())?;
154        }
155    }
156    Ok(())
157}
158
159#[inline]
160fn is_legacy_markdown_entry_name(file_name: &str) -> bool {
161    let Some(hash_part) = file_name.strip_suffix(".md") else {
162        return false;
163    };
164    hash_part.len() == 64 && hash_part.bytes().all(|byte| byte.is_ascii_hexdigit())
165}