1use std::fs;
2use std::path::Path;
3
4use anyhow::{bail, Result};
5use rusqlite::{params, Connection, OptionalExtension};
6
7use crate::types::{
8 IndexedImport, IndexedReference, IndexedSymbol, Language, ReferenceRecord, SymbolRecord,
9};
10
11pub const SCHEMA_VERSION: i64 = 3;
12
13pub fn open(path: &Path) -> Result<Connection> {
14 if let Some(parent) = path.parent() {
15 if !parent.as_os_str().is_empty() {
16 fs::create_dir_all(parent)?;
17 }
18 }
19 let conn = Connection::open(path)?;
20 conn.pragma_update(None, "journal_mode", "WAL")?;
21 conn.pragma_update(None, "foreign_keys", "ON")?;
22 conn.pragma_update(None, "synchronous", "NORMAL")?;
23 migrate(&conn)?;
24 Ok(conn)
25}
26
27pub fn open_existing(path: &Path) -> Result<Connection> {
28 if !path.exists() {
29 bail!(
30 "Tessera database not found at {}. Run `tessera index . --db {}` first.",
31 path.display(),
32 path.display()
33 );
34 }
35 open(path)
36}
37
38pub fn reset(conn: &Connection) -> Result<()> {
39 conn.execute_batch(
44 "
45 DELETE FROM imports;
46 DELETE FROM edges;
47 DELETE FROM refs;
48 DELETE FROM symbols;
49 DELETE FROM files;
50 ",
51 )?;
52 let _ = conn.execute_batch("INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all');");
54 Ok(())
55}
56
57fn migrate(conn: &Connection) -> Result<()> {
58 conn.execute_batch(
59 "
60 CREATE TABLE IF NOT EXISTS files (
61 id INTEGER PRIMARY KEY,
62 path TEXT NOT NULL UNIQUE,
63 language TEXT NOT NULL,
64 sha256 TEXT NOT NULL,
65 loc INTEGER NOT NULL,
66 indexed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
67 );
68
69 CREATE TABLE IF NOT EXISTS meta (
70 key TEXT PRIMARY KEY,
71 value TEXT NOT NULL
72 );
73
74 CREATE TABLE IF NOT EXISTS meta_blob (
75 key TEXT PRIMARY KEY,
76 value BLOB NOT NULL
77 );
78
79 CREATE TABLE IF NOT EXISTS symbols (
80 id INTEGER PRIMARY KEY,
81 name TEXT NOT NULL,
82 qualified_name TEXT NOT NULL,
83 kind TEXT NOT NULL,
84 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
85 start_line INTEGER NOT NULL,
86 end_line INTEGER NOT NULL,
87 signature TEXT NOT NULL,
88 exported INTEGER NOT NULL DEFAULT 0
89 );
90
91 CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
92 CREATE INDEX IF NOT EXISTS idx_symbols_qualified ON symbols(qualified_name);
93 CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
94
95 CREATE TABLE IF NOT EXISTS refs (
96 id INTEGER PRIMARY KEY,
97 symbol_name TEXT NOT NULL,
98 from_symbol_id INTEGER REFERENCES symbols(id) ON DELETE SET NULL,
99 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
100 line INTEGER NOT NULL,
101 column INTEGER NOT NULL,
102 context TEXT NOT NULL,
103 kind TEXT NOT NULL
104 );
105
106 CREATE INDEX IF NOT EXISTS idx_refs_symbol ON refs(symbol_name);
107 CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_symbol_id);
108 CREATE INDEX IF NOT EXISTS idx_refs_file ON refs(file_id);
109
110 CREATE TABLE IF NOT EXISTS edges (
111 id INTEGER PRIMARY KEY,
112 from_symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
113 to_symbol_name TEXT NOT NULL,
114 kind TEXT NOT NULL,
115 weight REAL NOT NULL DEFAULT 1.0
116 );
117
118 CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_symbol_name);
119 CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_symbol_id);
120
121 CREATE TABLE IF NOT EXISTS imports (
122 id INTEGER PRIMARY KEY,
123 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
124 source TEXT NOT NULL,
125 line INTEGER NOT NULL,
126 kind TEXT NOT NULL
127 );
128
129 CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file_id);
130 CREATE INDEX IF NOT EXISTS idx_imports_source ON imports(source);
131 ",
132 )?;
133
134 let fts_setup = "
137 CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
138 name, qualified_name,
139 content='symbols', content_rowid='id',
140 tokenize='trigram'
141 );
142
143 CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
144 INSERT INTO symbols_fts(rowid, name, qualified_name)
145 VALUES (new.id, new.name, new.qualified_name);
146 END;
147
148 CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
149 INSERT INTO symbols_fts(symbols_fts, rowid, name, qualified_name)
150 VALUES('delete', old.id, old.name, old.qualified_name);
151 END;
152 ";
153 let _ = conn.execute_batch(fts_setup);
155
156 set_meta(conn, "schema_version", &SCHEMA_VERSION.to_string())?;
157 Ok(())
158}
159
160pub fn set_meta(conn: &Connection, key: &str, value: &str) -> Result<()> {
161 conn.execute(
162 "
163 INSERT INTO meta(key, value) VALUES (?1, ?2)
164 ON CONFLICT(key) DO UPDATE SET value = excluded.value
165 ",
166 params![key, value],
167 )?;
168 Ok(())
169}
170
171pub fn get_meta(conn: &Connection, key: &str) -> Result<Option<String>> {
172 conn.query_row(
173 "SELECT value FROM meta WHERE key = ?1",
174 params![key],
175 |row| row.get(0),
176 )
177 .optional()
178 .map_err(Into::into)
179}
180
181pub fn set_meta_blob(conn: &Connection, key: &str, value: &[u8]) -> Result<()> {
182 conn.execute(
183 "
184 INSERT INTO meta_blob(key, value) VALUES (?1, ?2)
185 ON CONFLICT(key) DO UPDATE SET value = excluded.value
186 ",
187 params![key, value],
188 )?;
189 Ok(())
190}
191
192pub fn get_meta_blob(conn: &Connection, key: &str) -> Result<Option<Vec<u8>>> {
193 conn.query_row(
194 "SELECT value FROM meta_blob WHERE key = ?1",
195 params![key],
196 |row| row.get::<_, Vec<u8>>(0),
197 )
198 .optional()
199 .map_err(Into::into)
200}
201
202pub fn file_sha(conn: &Connection, path: &str) -> Result<Option<(i64, String)>> {
203 conn.query_row(
204 "SELECT id, sha256 FROM files WHERE path = ?1",
205 params![path],
206 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
207 )
208 .optional()
209 .map_err(Into::into)
210}
211
212pub fn delete_file_cascade(conn: &Connection, file_id: i64) -> Result<()> {
213 conn.execute("DELETE FROM files WHERE id = ?1", params![file_id])?;
214 Ok(())
215}
216
217pub fn delete_files_not_in(conn: &Connection, retained_ids: &[i64]) -> Result<usize> {
218 if retained_ids.is_empty() {
219 let removed = conn.execute("DELETE FROM files", [])?;
220 return Ok(removed);
221 }
222 let placeholders = retained_ids
223 .iter()
224 .map(|_| "?")
225 .collect::<Vec<_>>()
226 .join(",");
227 let sql = format!("DELETE FROM files WHERE id NOT IN ({placeholders})");
228 let params_iter: Vec<&dyn rusqlite::ToSql> = retained_ids
229 .iter()
230 .map(|id| id as &dyn rusqlite::ToSql)
231 .collect();
232 let removed = conn.execute(&sql, params_iter.as_slice())?;
233 Ok(removed)
234}
235
236pub fn insert_file(
237 conn: &Connection,
238 path: &str,
239 language: Language,
240 sha256: &str,
241 loc: usize,
242) -> Result<i64> {
243 conn.execute(
244 "INSERT INTO files(path, language, sha256, loc) VALUES (?1, ?2, ?3, ?4)",
245 params![path, language.to_string(), sha256, loc as i64],
246 )?;
247 Ok(conn.last_insert_rowid())
248}
249
250pub fn insert_symbols(
251 conn: &Connection,
252 file_id: i64,
253 symbols: &[IndexedSymbol],
254) -> Result<Vec<i64>> {
255 let mut ids = Vec::with_capacity(symbols.len());
256 for symbol in symbols {
257 conn.execute(
258 "
259 INSERT INTO symbols(
260 name, qualified_name, kind, file_id, start_line, end_line, signature, exported
261 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
262 ",
263 params![
264 symbol.name,
265 symbol.qualified_name,
266 symbol.kind,
267 file_id,
268 symbol.start_line as i64,
269 symbol.end_line as i64,
270 symbol.signature,
271 i64::from(symbol.exported)
272 ],
273 )?;
274 ids.push(conn.last_insert_rowid());
275 }
276 Ok(ids)
277}
278
279pub fn find_symbol_id(
280 conn: &Connection,
281 file_id: i64,
282 qualified_name: &str,
283) -> Result<Option<i64>> {
284 conn.query_row(
285 "SELECT id FROM symbols WHERE file_id = ?1 AND qualified_name = ?2",
286 params![file_id, qualified_name],
287 |row| row.get(0),
288 )
289 .optional()
290 .map_err(Into::into)
291}
292
293pub fn insert_imports(conn: &Connection, file_id: i64, imports: &[IndexedImport]) -> Result<usize> {
294 for imp in imports {
295 conn.execute(
296 "INSERT INTO imports(file_id, source, line, kind) VALUES (?1, ?2, ?3, ?4)",
297 params![file_id, imp.source, imp.line as i64, imp.kind],
298 )?;
299 }
300 Ok(imports.len())
301}
302
303pub fn insert_references(
304 conn: &Connection,
305 file_id: i64,
306 refs: &[IndexedReference],
307) -> Result<usize> {
308 let mut count = 0;
309 for reference in refs {
310 let from_symbol_id = reference
311 .from_qualified_name
312 .as_deref()
313 .map(|name| find_symbol_id(conn, file_id, name))
314 .transpose()?
315 .flatten();
316 conn.execute(
317 "
318 INSERT INTO refs(
319 symbol_name, from_symbol_id, file_id, line, column, context, kind
320 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
321 ",
322 params![
323 reference.symbol_name,
324 from_symbol_id,
325 file_id,
326 reference.line as i64,
327 reference.column as i64,
328 reference.context,
329 reference.kind
330 ],
331 )?;
332 if let Some(from_symbol_id) = from_symbol_id {
333 conn.execute(
334 "
335 INSERT INTO edges(from_symbol_id, to_symbol_name, kind, weight)
336 VALUES (?1, ?2, ?3, 1.0)
337 ",
338 params![from_symbol_id, reference.symbol_name, reference.kind],
339 )?;
340 }
341 count += 1;
342 }
343 Ok(count)
344}
345
346pub fn resolve_symbol(conn: &Connection, symbol: &str) -> Result<Option<SymbolRecord>> {
347 conn.query_row(
348 "
349 SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
350 s.start_line, s.end_line, s.signature, s.exported
351 FROM symbols s
352 JOIN files f ON f.id = s.file_id
353 WHERE s.qualified_name = ?1 OR s.name = ?1 OR s.qualified_name LIKE ?2
354 ORDER BY
355 CASE
356 WHEN s.qualified_name = ?1 THEN 0
357 WHEN s.name = ?1 THEN 1
358 ELSE 2
359 END,
360 length(s.qualified_name)
361 LIMIT 1
362 ",
363 params![symbol, format!("%.{}", symbol)],
364 map_symbol,
365 )
366 .optional()
367 .map_err(Into::into)
368}
369
370pub fn symbol_fanout(conn: &Connection, symbol_id: i64) -> Result<usize> {
371 conn.query_row(
372 "SELECT COUNT(DISTINCT to_symbol_name) FROM edges WHERE from_symbol_id = ?1",
373 params![symbol_id],
374 |row| row.get::<_, i64>(0),
375 )
376 .map(|count| count as usize)
377 .map_err(Into::into)
378}
379
380pub fn symbol_callers_count(conn: &Connection, symbol_name: &str) -> Result<usize> {
381 conn.query_row(
382 "SELECT COUNT(DISTINCT from_symbol_id) FROM edges WHERE to_symbol_name = ?1",
383 params![symbol_name],
384 |row| row.get::<_, i64>(0),
385 )
386 .map(|count| count as usize)
387 .map_err(Into::into)
388}
389
390pub fn count_imports(conn: &Connection) -> Result<usize> {
391 conn.query_row("SELECT COUNT(*) FROM imports", [], |row| {
392 row.get::<_, i64>(0)
393 })
394 .map(|c| c as usize)
395 .map_err(Into::into)
396}
397
398pub fn count_files(conn: &Connection) -> Result<usize> {
399 conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0))
400 .map(|c| c as usize)
401 .map_err(Into::into)
402}
403
404pub fn count_symbols(conn: &Connection) -> Result<usize> {
405 conn.query_row("SELECT COUNT(*) FROM symbols", [], |row| {
406 row.get::<_, i64>(0)
407 })
408 .map(|c| c as usize)
409 .map_err(Into::into)
410}
411
412pub fn count_refs(conn: &Connection) -> Result<usize> {
413 conn.query_row("SELECT COUNT(*) FROM refs", [], |row| row.get::<_, i64>(0))
414 .map(|c| c as usize)
415 .map_err(Into::into)
416}
417
418pub fn count_edges(conn: &Connection) -> Result<usize> {
419 conn.query_row("SELECT COUNT(*) FROM edges", [], |row| row.get::<_, i64>(0))
420 .map(|c| c as usize)
421 .map_err(Into::into)
422}
423
424pub fn map_symbol(row: &rusqlite::Row<'_>) -> rusqlite::Result<SymbolRecord> {
425 Ok(SymbolRecord {
426 id: row.get(0)?,
427 name: row.get(1)?,
428 qualified_name: row.get(2)?,
429 kind: row.get(3)?,
430 file_id: row.get(4)?,
431 path: row.get(5)?,
432 language: row.get(6)?,
433 start_line: row.get::<_, i64>(7)? as usize,
434 end_line: row.get::<_, i64>(8)? as usize,
435 signature: row.get(9)?,
436 exported: row.get::<_, i64>(10)? != 0,
437 })
438}
439
440pub fn map_reference(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReferenceRecord> {
441 Ok(ReferenceRecord {
442 id: row.get(0)?,
443 symbol_name: row.get(1)?,
444 from_symbol_id: row.get(2)?,
445 from_symbol: row.get(3)?,
446 path: row.get(4)?,
447 line: row.get::<_, i64>(5)? as usize,
448 column: row.get::<_, i64>(6)? as usize,
449 context: row.get(7)?,
450 kind: row.get(8)?,
451 })
452}