1use rusqlite::Connection;
2
3pub struct Cache {}
4
5#[derive(Debug, Clone)]
6pub struct FileRecord {
7 pub id: i64,
8 pub path: String,
9 pub hash: String,
10 pub content: Vec<u8>,
11}
12
13#[derive(Debug, Clone)]
14pub struct Symbol {
15 pub id: String,
16 pub file_id: i64,
17 pub name: String,
18 pub kind: String,
19 pub byte_offset: usize,
20 pub byte_length: usize,
21}
22
23pub trait CacheStore {
24 fn init(&self) -> rusqlite::Result<()>;
26
27 fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64>;
29
30 fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>>;
32
33 fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()>;
35
36 fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>>;
38
39 fn delete_file(&self, path: &str) -> rusqlite::Result<()>;
41
42 fn get_file_symbols(&self, path: &str) -> rusqlite::Result<Vec<(String, String)>>;
44}
45
46pub struct SqliteCache {
47 pub conn: Connection,
48}
49
50impl SqliteCache {
51 pub fn new(db_path: &str) -> rusqlite::Result<Self> {
52 let conn = Connection::open(db_path)?;
53 conn.execute("PRAGMA foreign_keys = ON", [])?;
55 Ok(Self { conn })
56 }
57
58 pub fn new_in_memory() -> rusqlite::Result<Self> {
59 let conn = Connection::open_in_memory()?;
60 conn.execute("PRAGMA foreign_keys = ON", [])?;
61 Ok(Self { conn })
62 }
63}
64
65impl CacheStore for SqliteCache {
66 fn init(&self) -> rusqlite::Result<()> {
67 self.conn.execute_batch(
68 "CREATE TABLE IF NOT EXISTS files (
69 id INTEGER PRIMARY KEY AUTOINCREMENT,
70 path TEXT NOT NULL UNIQUE,
71 hash TEXT NOT NULL,
72 content BLOB NOT NULL
73 );
74 CREATE TABLE IF NOT EXISTS symbols (
75 id TEXT PRIMARY KEY,
76 file_id INTEGER NOT NULL,
77 name TEXT NOT NULL,
78 kind TEXT NOT NULL,
79 byte_offset INTEGER NOT NULL,
80 byte_length INTEGER NOT NULL,
81 FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE
82 );
83 CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id);
84 CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);",
85 )?;
86 Ok(())
87 }
88
89 fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64> {
90 self.conn.query_row(
91 "INSERT INTO files (path, hash, content) VALUES (?1, ?2, ?3)
92 ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, content=excluded.content
93 RETURNING id",
94 rusqlite::params![path, hash, content],
95 |row| row.get(0),
96 )
97 }
98
99 fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>> {
100 let mut stmt = self
101 .conn
102 .prepare("SELECT hash FROM files WHERE path = ?1")?;
103 let mut rows = stmt.query(rusqlite::params![path])?;
104 if let Some(row) = rows.next()? {
105 Ok(Some(row.get(0)?))
106 } else {
107 Ok(None)
108 }
109 }
110
111 fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()> {
112 self.conn.execute(
113 "INSERT INTO symbols (id, file_id, name, kind, byte_offset, byte_length)
114 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
115 ON CONFLICT(id) DO UPDATE SET
116 file_id=excluded.file_id,
117 name=excluded.name,
118 kind=excluded.kind,
119 byte_offset=excluded.byte_offset,
120 byte_length=excluded.byte_length",
121 rusqlite::params![
122 symbol.id,
123 symbol.file_id,
124 symbol.name,
125 symbol.kind,
126 symbol.byte_offset as i64,
127 symbol.byte_length as i64,
128 ],
129 )?;
130 Ok(())
131 }
132
133 fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>> {
134 let mut stmt = self.conn.prepare(
135 "SELECT substr(f.content, s.byte_offset + 1, s.byte_length)
136 FROM symbols s
137 JOIN files f ON s.file_id = f.id
138 WHERE s.id = ?1",
139 )?;
140 let mut rows = stmt.query(rusqlite::params![symbol_id])?;
141 if let Some(row) = rows.next()? {
142 Ok(Some(row.get(0)?))
143 } else {
144 Ok(None)
145 }
146 }
147
148 fn delete_file(&self, path: &str) -> rusqlite::Result<()> {
149 self.conn
150 .execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?;
151 Ok(())
152 }
153
154 fn get_file_symbols(&self, path: &str) -> rusqlite::Result<Vec<(String, String)>> {
155 let mut stmt = self.conn.prepare(
156 "SELECT s.kind, s.name FROM symbols s
157 JOIN files f ON s.file_id = f.id
158 WHERE f.path = ?1
159 ORDER BY s.byte_offset ASC",
160 )?;
161 let mut rows = stmt.query(rusqlite::params![path])?;
162 let mut symbols = Vec::new();
163 while let Some(row) = rows.next()? {
164 let kind: String = row.get(0)?;
165 let name: String = row.get(1)?;
166 symbols.push((kind, name));
167 }
168 Ok(symbols)
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn test_should_initialize_schema_successfully() {
178 let cache = SqliteCache::new_in_memory().unwrap();
179 cache.init().unwrap();
180
181 let mut stmt = cache.conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('files', 'symbols')").unwrap();
182 let tables: Vec<String> = stmt
183 .query_map([], |row| row.get(0))
184 .unwrap()
185 .collect::<Result<_, _>>()
186 .unwrap();
187
188 assert!(tables.contains(&"files".to_string()));
189 assert!(tables.contains(&"symbols".to_string()));
190 }
191
192 #[test]
193 fn test_should_upsert_a_file_and_return_its_id() {
194 let cache = SqliteCache::new_in_memory().unwrap();
195 cache.init().unwrap();
196
197 let id = cache
198 .upsert_file("src/main.rs", "hash123", b"fn main() {}")
199 .unwrap();
200 assert!(id > 0);
201
202 let mut stmt = cache
203 .conn
204 .prepare("SELECT path, hash FROM files WHERE id = ?")
205 .unwrap();
206 let (path, hash): (String, String) = stmt
207 .query_row([id], |row| Ok((row.get(0)?, row.get(1)?)))
208 .unwrap();
209
210 assert_eq!(path, "src/main.rs");
211 assert_eq!(hash, "hash123");
212 }
213
214 #[test]
215 fn test_should_return_correct_file_hash_for_existing_file() {
216 let cache = SqliteCache::new_in_memory().unwrap();
217 cache.init().unwrap();
218
219 cache
220 .upsert_file("src/main.rs", "hash123", b"fn main() {}")
221 .unwrap();
222
223 let hash = cache.get_file_hash("src/main.rs").unwrap();
224 assert_eq!(hash, Some("hash123".to_string()));
225 }
226
227 #[test]
228 fn test_should_return_none_for_missing_file_hash() {
229 let cache = SqliteCache::new_in_memory().unwrap();
230 cache.init().unwrap();
231
232 let hash = cache.get_file_hash("missing.rs").unwrap();
233 assert_eq!(hash, None);
234 }
235
236 #[test]
237 fn test_should_insert_a_symbol_and_retrieve_its_content_via_substr() {
238 let cache = SqliteCache::new_in_memory().unwrap();
239 cache.init().unwrap();
240
241 let content = b"pub fn foo() {}\npub fn bar() {}";
242 let file_id = cache.upsert_file("src/lib.rs", "hash456", content).unwrap();
243
244 let symbol = Symbol {
245 id: "sym_bar".to_string(),
246 file_id,
247 name: "bar".to_string(),
248 kind: "function".to_string(),
249 byte_offset: 16,
250 byte_length: 15,
251 };
252 cache.insert_symbol(&symbol).unwrap();
253
254 let retrieved = cache.get_symbol_content("sym_bar").unwrap();
255 assert_eq!(retrieved, Some(b"pub fn bar() {}".to_vec()));
256 }
257
258 #[test]
259 fn test_should_return_none_for_missing_symbol_content() {
260 let cache = SqliteCache::new_in_memory().unwrap();
261 cache.init().unwrap();
262
263 let retrieved = cache.get_symbol_content("missing_id").unwrap();
264 assert_eq!(retrieved, None);
265 }
266
267 #[test]
268 fn test_should_cascade_delete_symbols_when_file_is_deleted() {
269 let cache = SqliteCache::new_in_memory().unwrap();
270 cache.init().unwrap();
271
272 let file_id = cache
273 .upsert_file("src/temp.rs", "hash789", b"fn temp() {}")
274 .unwrap();
275
276 let symbol = Symbol {
277 id: "sym_temp".to_string(),
278 file_id,
279 name: "temp".to_string(),
280 kind: "function".to_string(),
281 byte_offset: 0,
282 byte_length: 12,
283 };
284 cache.insert_symbol(&symbol).unwrap();
285
286 cache.delete_file("src/temp.rs").unwrap();
287
288 let mut stmt = cache
289 .conn
290 .prepare("SELECT COUNT(*) FROM symbols WHERE file_id = ?")
291 .unwrap();
292 let count: i64 = stmt.query_row([file_id], |row| row.get(0)).unwrap();
293
294 assert_eq!(count, 0);
295 }
296}