infiniloom_engine/index/
storage.rs

1//! Index storage and serialization.
2//!
3//! Handles saving and loading the symbol index and dependency graph
4//! using bincode for fast binary serialization.
5
6use super::types::{DepGraph, SymbolIndex};
7use std::fs::{self, File};
8use std::io::{BufReader, BufWriter, Write};
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12/// Index storage directory name
13pub const INDEX_DIR: &str = ".infiniloom";
14
15/// Index file names
16pub const INDEX_FILE: &str = "index.bin";
17pub const GRAPH_FILE: &str = "graph.bin";
18pub const META_FILE: &str = "meta.json";
19pub const CONFIG_FILE: &str = "config.toml";
20
21/// Errors that can occur during index storage operations
22#[derive(Error, Debug)]
23pub enum StorageError {
24    #[error("IO error: {0}")]
25    Io(#[from] std::io::Error),
26
27    #[error("Serialization error: {0}")]
28    Serialize(#[from] bincode::Error),
29
30    #[error("JSON error: {0}")]
31    Json(#[from] serde_json::Error),
32
33    #[error("Index not found at {0}")]
34    NotFound(PathBuf),
35
36    #[error("Index version mismatch: found {found}, expected {expected}")]
37    VersionMismatch { found: u32, expected: u32 },
38
39    #[error("Invalid index directory: {0}")]
40    InvalidDirectory(String),
41}
42
43/// Metadata about the index (human-readable JSON)
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct IndexMeta {
46    /// Index version
47    pub version: u32,
48    /// Repository name
49    pub repo_name: String,
50    /// Git commit hash when index was built
51    pub commit_hash: Option<String>,
52    /// Timestamp of index creation (Unix epoch seconds)
53    pub created_at: u64,
54    /// Number of files indexed
55    pub file_count: usize,
56    /// Number of symbols indexed
57    pub symbol_count: usize,
58    /// Total size of index files in bytes
59    pub index_size_bytes: u64,
60}
61
62/// Index storage manager
63pub struct IndexStorage {
64    /// Path to the index directory (.infiniloom)
65    index_dir: PathBuf,
66}
67
68impl IndexStorage {
69    /// Create a new storage manager for a repository
70    pub fn new(repo_root: impl AsRef<Path>) -> Self {
71        Self { index_dir: repo_root.as_ref().join(INDEX_DIR) }
72    }
73
74    /// Get path to the index directory
75    pub fn index_dir(&self) -> &Path {
76        &self.index_dir
77    }
78
79    /// Check if index exists
80    pub fn exists(&self) -> bool {
81        self.index_dir.join(INDEX_FILE).exists() && self.index_dir.join(GRAPH_FILE).exists()
82    }
83
84    /// Initialize the index directory structure
85    pub fn init(&self) -> Result<(), StorageError> {
86        // Create .infiniloom directory
87        fs::create_dir_all(&self.index_dir)?;
88
89        // Create .gitignore for temporary files only
90        let gitignore_path = self.index_dir.join(".gitignore");
91        if !gitignore_path.exists() {
92            fs::write(&gitignore_path, "*.tmp\n*.lock\n")?;
93        }
94
95        Ok(())
96    }
97
98    /// Save the symbol index to disk
99    pub fn save_index(&self, index: &SymbolIndex) -> Result<(), StorageError> {
100        self.init()?;
101
102        let path = self.index_dir.join(INDEX_FILE);
103        let tmp_path = self.index_dir.join(format!("{}.tmp", INDEX_FILE));
104
105        // Write to temp file first for atomicity
106        let file = File::create(&tmp_path)?;
107        let mut writer = BufWriter::new(file);
108        bincode::serialize_into(&mut writer, index)?;
109        writer.flush()?;
110
111        // Atomic rename
112        fs::rename(&tmp_path, &path)?;
113
114        Ok(())
115    }
116
117    /// Load the symbol index from disk
118    pub fn load_index(&self) -> Result<SymbolIndex, StorageError> {
119        let path = self.index_dir.join(INDEX_FILE);
120
121        if !path.exists() {
122            return Err(StorageError::NotFound(path));
123        }
124
125        let file = File::open(&path)?;
126        let reader = BufReader::new(file);
127        let mut index: SymbolIndex = bincode::deserialize_from(reader)?;
128
129        // Check version compatibility
130        if index.version != SymbolIndex::CURRENT_VERSION {
131            return Err(StorageError::VersionMismatch {
132                found: index.version,
133                expected: SymbolIndex::CURRENT_VERSION,
134            });
135        }
136
137        // Rebuild lookup tables
138        index.rebuild_lookups();
139
140        Ok(index)
141    }
142
143    /// Save the dependency graph to disk
144    pub fn save_graph(&self, graph: &DepGraph) -> Result<(), StorageError> {
145        self.init()?;
146
147        let path = self.index_dir.join(GRAPH_FILE);
148        let tmp_path = self.index_dir.join(format!("{}.tmp", GRAPH_FILE));
149
150        let file = File::create(&tmp_path)?;
151        let mut writer = BufWriter::new(file);
152        bincode::serialize_into(&mut writer, graph)?;
153        writer.flush()?;
154
155        fs::rename(&tmp_path, &path)?;
156
157        Ok(())
158    }
159
160    /// Load the dependency graph from disk
161    pub fn load_graph(&self) -> Result<DepGraph, StorageError> {
162        let path = self.index_dir.join(GRAPH_FILE);
163
164        if !path.exists() {
165            return Err(StorageError::NotFound(path));
166        }
167
168        let file = File::open(&path)?;
169        let reader = BufReader::new(file);
170        let graph: DepGraph = bincode::deserialize_from(reader)?;
171
172        Ok(graph)
173    }
174
175    /// Save index metadata (human-readable JSON)
176    pub fn save_meta(&self, meta: &IndexMeta) -> Result<(), StorageError> {
177        self.init()?;
178
179        let path = self.index_dir.join(META_FILE);
180        let json = serde_json::to_string_pretty(meta)?;
181        fs::write(&path, json)?;
182
183        Ok(())
184    }
185
186    /// Load index metadata
187    pub fn load_meta(&self) -> Result<IndexMeta, StorageError> {
188        let path = self.index_dir.join(META_FILE);
189
190        if !path.exists() {
191            return Err(StorageError::NotFound(path));
192        }
193
194        let content = fs::read_to_string(&path)?;
195        let meta: IndexMeta = serde_json::from_str(&content)?;
196
197        Ok(meta)
198    }
199
200    /// Save everything (index, graph, meta) atomically
201    pub fn save_all(
202        &self,
203        index: &SymbolIndex,
204        graph: &DepGraph,
205    ) -> Result<IndexMeta, StorageError> {
206        // Save index and graph
207        self.save_index(index)?;
208        self.save_graph(graph)?;
209
210        // Calculate sizes
211        let index_size = fs::metadata(self.index_dir.join(INDEX_FILE))?.len();
212        let graph_size = fs::metadata(self.index_dir.join(GRAPH_FILE))?.len();
213
214        // Create and save metadata
215        let meta = IndexMeta {
216            version: index.version,
217            repo_name: index.repo_name.clone(),
218            commit_hash: index.commit_hash.clone(),
219            created_at: index.created_at,
220            file_count: index.files.len(),
221            symbol_count: index.symbols.len(),
222            index_size_bytes: index_size + graph_size,
223        };
224
225        self.save_meta(&meta)?;
226
227        Ok(meta)
228    }
229
230    /// Load everything (index, graph)
231    pub fn load_all(&self) -> Result<(SymbolIndex, DepGraph), StorageError> {
232        let index = self.load_index()?;
233        let graph = self.load_graph()?;
234        Ok((index, graph))
235    }
236
237    /// Get size of stored index files
238    pub fn storage_size(&self) -> u64 {
239        let mut total = 0u64;
240
241        for name in [INDEX_FILE, GRAPH_FILE, META_FILE] {
242            if let Ok(metadata) = fs::metadata(self.index_dir.join(name)) {
243                total += metadata.len();
244            }
245        }
246
247        total
248    }
249
250    /// Delete the index
251    pub fn delete(&self) -> Result<(), StorageError> {
252        if self.index_dir.exists() {
253            fs::remove_dir_all(&self.index_dir)?;
254        }
255        Ok(())
256    }
257}
258
259// Note: Memory-mapped index loader can be added as a future optimization
260// for very large repositories. For now, the standard file-based loader
261// is sufficient and provides good performance.
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::index::types::{
267        FileEntry, FileId, IndexSymbol, IndexSymbolKind, Language, Span, SymbolId, Visibility,
268    };
269    use tempfile::TempDir;
270
271    #[test]
272    fn test_storage_roundtrip() {
273        let tmp = TempDir::new().unwrap();
274        let storage = IndexStorage::new(tmp.path());
275
276        // Create test index
277        let mut index = SymbolIndex::new();
278        index.repo_name = "test-repo".to_owned();
279        index.created_at = 12345;
280        index.files.push(FileEntry {
281            id: FileId::new(0),
282            path: "src/main.rs".to_owned(),
283            language: Language::Rust,
284            content_hash: [1; 32],
285            symbols: 0..1,
286            imports: vec![],
287            lines: 100,
288            tokens: 500,
289        });
290        index.symbols.push(IndexSymbol {
291            id: SymbolId::new(0),
292            name: "main".to_owned(),
293            kind: IndexSymbolKind::Function,
294            file_id: FileId::new(0),
295            span: Span::new(1, 0, 10, 0),
296            signature: Some("fn main()".to_owned()),
297            parent: None,
298            visibility: Visibility::Public,
299            docstring: None,
300        });
301
302        // Create test graph
303        let mut graph = DepGraph::new();
304        graph.add_file_import(0, 1);
305
306        // Save
307        storage.save_all(&index, &graph).unwrap();
308
309        // Verify files exist
310        assert!(storage.exists());
311        assert!(storage.storage_size() > 0);
312
313        // Load and verify
314        let (loaded_index, loaded_graph) = storage.load_all().unwrap();
315        assert_eq!(loaded_index.repo_name, "test-repo");
316        assert_eq!(loaded_index.files.len(), 1);
317        assert_eq!(loaded_index.symbols.len(), 1);
318        assert_eq!(loaded_graph.file_imports.len(), 1);
319
320        // Verify lookups work
321        assert!(loaded_index.get_file("src/main.rs").is_some());
322    }
323
324    #[test]
325    fn test_meta_roundtrip() {
326        let tmp = TempDir::new().unwrap();
327        let storage = IndexStorage::new(tmp.path());
328        storage.init().unwrap();
329
330        let meta = IndexMeta {
331            version: 1,
332            repo_name: "test".to_owned(),
333            commit_hash: Some("abc123".to_owned()),
334            created_at: 12345,
335            file_count: 10,
336            symbol_count: 100,
337            index_size_bytes: 1024,
338        };
339
340        storage.save_meta(&meta).unwrap();
341        let loaded = storage.load_meta().unwrap();
342
343        assert_eq!(loaded.repo_name, "test");
344        assert_eq!(loaded.file_count, 10);
345    }
346
347    #[test]
348    fn test_not_found() {
349        let tmp = TempDir::new().unwrap();
350        let storage = IndexStorage::new(tmp.path());
351
352        assert!(!storage.exists());
353        assert!(matches!(storage.load_index(), Err(StorageError::NotFound(_))));
354    }
355}