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