Skip to main content

codebones_core/
cache.rs

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    /// Initialize the database schema
25    fn init(&self) -> rusqlite::Result<()>;
26
27    /// Insert or update a file and its content. Returns the file_id.
28    fn upsert_file(&self, path: &str, hash: &str, content: &[u8]) -> rusqlite::Result<i64>;
29
30    /// Get a file's hash to check if it has changed
31    fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>>;
32
33    /// Insert a symbol
34    fn insert_symbol(&self, symbol: &Symbol) -> rusqlite::Result<()>;
35
36    /// Retrieve the raw bytes of a symbol using SQLite substr()
37    fn get_symbol_content(&self, symbol_id: &str) -> rusqlite::Result<Option<Vec<u8>>>;
38
39    /// Delete a file and cascade delete its symbols
40    fn delete_file(&self, path: &str) -> rusqlite::Result<()>;
41
42    /// Get symbols for a file
43    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        // Enable foreign keys for cascading deletes
54        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}