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
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        // Enable foreign keys for cascading deletes
51        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}