Skip to main content

reposcry_cache/
db.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use rusqlite::{params, Connection};
5use serde::{Deserialize, Serialize};
6
7use reposcry_graph::edge::EdgeKind;
8use reposcry_graph::symbol::{CallSite, Import, Symbol};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CachedFile {
12    pub id: i64,
13    pub path: String,
14    pub language: String,
15    pub hash: String,
16    pub size_bytes: i64,
17    pub loc: i64,
18    pub last_indexed_at: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CachedImport {
23    pub id: i64,
24    pub file_id: i64,
25    pub source: String,
26    pub target: String,
27    pub is_relative: bool,
28    pub imported_names: Vec<String>,
29    pub line: u32,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CachedEdge {
34    pub id: i64,
35    pub source_file_id: i64,
36    pub target_file_id: Option<i64>,
37    pub target_path: Option<String>,
38    pub kind: String,
39    pub confidence: f64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct CachedCallSite {
44    pub id: i64,
45    pub file_id: i64,
46    pub caller: String,
47    pub callee: String,
48    pub line: u32,
49    pub confidence: f64,
50    pub resolution_strategy: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CachedSymbolEdge {
55    pub id: i64,
56    pub source_symbol_id: i64,
57    pub target_symbol_id: i64,
58    pub source_file_id: i64,
59    pub target_file_id: i64,
60    pub kind: String,
61    pub line: u32,
62    pub confidence: f64,
63    pub resolution_strategy: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CachedSearchHit {
68    pub node_id: i64,
69    pub file_path: String,
70    pub kind: String,
71    pub name: String,
72    pub signature: Option<String>,
73    pub score: f64,
74    pub match_reason: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CachedSearchVector {
79    pub node_id: i64,
80    pub file_path: String,
81    pub kind: String,
82    pub name: String,
83    pub signature: Option<String>,
84    pub backend: String,
85    pub dims: u32,
86    pub vector: Vec<f32>,
87}
88
89pub struct CacheDb {
90    conn: Connection,
91}
92
93impl CacheDb {
94    pub fn open(path: &Path) -> Result<Self> {
95        if let Some(parent) = path.parent() {
96            std::fs::create_dir_all(parent)?;
97        }
98        let conn = Connection::open(path).context("Failed to open cache database")?;
99        let db = Self { conn };
100        db.initialize()?;
101        Ok(db)
102    }
103
104    pub fn open_in_memory() -> Result<Self> {
105        let conn = Connection::open_in_memory()?;
106        let db = Self { conn };
107        db.initialize()?;
108        Ok(db)
109    }
110
111    fn initialize(&self) -> Result<()> {
112        self.conn.execute_batch(
113            "
114            PRAGMA foreign_keys = ON;
115            PRAGMA journal_mode = WAL;
116            PRAGMA synchronous = NORMAL;
117            PRAGMA cache_size = -64000;
118            PRAGMA temp_store = MEMORY;
119            PRAGMA busy_timeout = 5000;
120            CREATE TABLE IF NOT EXISTS files (
121                id INTEGER PRIMARY KEY,
122                path TEXT UNIQUE NOT NULL,
123                language TEXT NOT NULL DEFAULT '',
124                hash TEXT NOT NULL,
125                size_bytes INTEGER NOT NULL DEFAULT 0,
126                loc INTEGER NOT NULL DEFAULT 0,
127                last_indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
128            );
129            CREATE TABLE IF NOT EXISTS symbols (
130                id INTEGER PRIMARY KEY,
131                file_id INTEGER NOT NULL,
132                name TEXT NOT NULL,
133                kind TEXT NOT NULL,
134                start_line INTEGER NOT NULL DEFAULT 0,
135                end_line INTEGER NOT NULL DEFAULT 0,
136                signature TEXT,
137                visibility TEXT,
138                doc_comment TEXT,
139                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
140            );
141            CREATE TABLE IF NOT EXISTS imports (
142                id INTEGER PRIMARY KEY,
143                file_id INTEGER NOT NULL,
144                source TEXT NOT NULL,
145                target TEXT NOT NULL,
146                is_relative INTEGER NOT NULL DEFAULT 0,
147                imported_names TEXT NOT NULL DEFAULT '[]',
148                line INTEGER NOT NULL DEFAULT 0,
149                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
150            );
151            CREATE TABLE IF NOT EXISTS edges (
152                id INTEGER PRIMARY KEY,
153                source_file_id INTEGER NOT NULL,
154                target_file_id INTEGER,
155                target_path TEXT,
156                kind TEXT NOT NULL,
157                confidence REAL NOT NULL DEFAULT 1.0,
158                FOREIGN KEY (source_file_id) REFERENCES files(id) ON DELETE CASCADE,
159                FOREIGN KEY (target_file_id) REFERENCES files(id) ON DELETE CASCADE
160            );
161            CREATE TABLE IF NOT EXISTS call_sites (
162                id INTEGER PRIMARY KEY,
163                file_id INTEGER NOT NULL,
164                caller TEXT NOT NULL,
165                callee TEXT NOT NULL,
166                line INTEGER NOT NULL DEFAULT 0,
167                confidence REAL NOT NULL DEFAULT 1.0,
168                resolution_strategy TEXT,
169                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
170            );
171            CREATE TABLE IF NOT EXISTS symbol_edges (
172                id INTEGER PRIMARY KEY,
173                source_symbol_id INTEGER NOT NULL,
174                target_symbol_id INTEGER NOT NULL,
175                source_file_id INTEGER NOT NULL,
176                target_file_id INTEGER NOT NULL,
177                kind TEXT NOT NULL,
178                line INTEGER NOT NULL DEFAULT 0,
179                confidence REAL NOT NULL DEFAULT 1.0,
180                resolution_strategy TEXT,
181                FOREIGN KEY (source_symbol_id) REFERENCES symbols(id) ON DELETE CASCADE,
182                FOREIGN KEY (target_symbol_id) REFERENCES symbols(id) ON DELETE CASCADE,
183                FOREIGN KEY (source_file_id) REFERENCES files(id) ON DELETE CASCADE,
184                FOREIGN KEY (target_file_id) REFERENCES files(id) ON DELETE CASCADE
185            );
186            CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
187                node_id UNINDEXED,
188                file_path,
189                kind,
190                name,
191                signature,
192                doc_comment,
193                imports,
194                content
195            );
196            CREATE TABLE IF NOT EXISTS search_vectors (
197                node_id INTEGER NOT NULL,
198                file_path TEXT NOT NULL,
199                kind TEXT NOT NULL,
200                name TEXT NOT NULL,
201                signature TEXT,
202                backend TEXT NOT NULL,
203                dims INTEGER NOT NULL,
204                vector BLOB NOT NULL,
205                PRIMARY KEY (node_id, backend)
206            );
207            CREATE TABLE IF NOT EXISTS git_changes (
208                id INTEGER PRIMARY KEY,
209                path TEXT NOT NULL,
210                status TEXT NOT NULL DEFAULT 'modified',
211                lines_added INTEGER NOT NULL DEFAULT 0,
212                lines_deleted INTEGER NOT NULL DEFAULT 0,
213                recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
214            );
215            CREATE TABLE IF NOT EXISTS config (
216                key TEXT PRIMARY KEY,
217                value TEXT NOT NULL DEFAULT ''
218            );
219            CREATE INDEX IF NOT EXISTS idx_symbols_file_id ON symbols(file_id);
220            CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
221            CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind);
222            CREATE INDEX IF NOT EXISTS idx_imports_file_id ON imports(file_id);
223            CREATE INDEX IF NOT EXISTS idx_imports_target ON imports(target);
224            CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
225            CREATE INDEX IF NOT EXISTS idx_edges_source_file_id ON edges(source_file_id);
226            CREATE INDEX IF NOT EXISTS idx_edges_target_file_id ON edges(target_file_id);
227            CREATE INDEX IF NOT EXISTS idx_call_sites_file_id ON call_sites(file_id);
228            CREATE INDEX IF NOT EXISTS idx_call_sites_callee ON call_sites(callee);
229            CREATE INDEX IF NOT EXISTS idx_symbol_edges_kind ON symbol_edges(kind);
230            CREATE INDEX IF NOT EXISTS idx_symbol_edges_source_file_id ON symbol_edges(source_file_id);
231            CREATE INDEX IF NOT EXISTS idx_symbol_edges_target_file_id ON symbol_edges(target_file_id);
232            CREATE INDEX IF NOT EXISTS idx_search_vectors_backend_kind ON search_vectors(backend, kind);
233            CREATE INDEX IF NOT EXISTS idx_git_changes_path ON git_changes(path);
234            ",
235        )?;
236        self.migrate_imports_table()?;
237        Ok(())
238    }
239
240    fn migrate_imports_table(&self) -> Result<()> {
241        let has_imported_names = {
242            let mut stmt = self.conn.prepare("PRAGMA table_info(imports)")?;
243            let columns = stmt.query_map([], |row| row.get::<_, String>(1))?;
244            let mut has_imported_names = false;
245            for col in columns {
246                if col? == "imported_names" {
247                    has_imported_names = true;
248                    break;
249                }
250            }
251            has_imported_names
252        };
253        if !has_imported_names {
254            self.conn.execute(
255                "ALTER TABLE imports ADD COLUMN imported_names TEXT NOT NULL DEFAULT '[]'",
256                [],
257            )?;
258        }
259        Ok(())
260    }
261
262    pub fn get_file_by_path(&self, path: &str) -> Result<Option<CachedFile>> {
263        let mut stmt = self.conn.prepare(
264            "SELECT id, path, language, hash, size_bytes, loc, last_indexed_at \
265             FROM files WHERE path = ?1",
266        )?;
267        let mut rows = stmt.query_map(params![path], |row| {
268            Ok(CachedFile {
269                id: row.get(0)?,
270                path: row.get(1)?,
271                language: row.get(2)?,
272                hash: row.get(3)?,
273                size_bytes: row.get(4)?,
274                loc: row.get(5)?,
275                last_indexed_at: row.get(6)?,
276            })
277        })?;
278        match rows.next() {
279            Some(Ok(file)) => Ok(Some(file)),
280            Some(Err(e)) => Err(e.into()),
281            None => Ok(None),
282        }
283    }
284
285    pub fn upsert_file(
286        &self,
287        path: &str,
288        language: &str,
289        hash: &str,
290        size_bytes: i64,
291        loc: i64,
292    ) -> Result<i64> {
293        self.conn.execute(
294            "INSERT INTO files (path, language, hash, size_bytes, loc, last_indexed_at) \
295             VALUES (?1, ?2, ?3, ?4, ?5, datetime('now')) \
296             ON CONFLICT(path) DO UPDATE SET \
297               language = excluded.language, \
298               hash = excluded.hash, \
299               size_bytes = excluded.size_bytes, \
300               loc = excluded.loc, \
301               last_indexed_at = datetime('now')",
302            params![path, language, hash, size_bytes, loc],
303        )?;
304        self.get_file_by_path(path)?
305            .map(|file| file.id)
306            .ok_or_else(|| anyhow::anyhow!("file not found after upsert: {}", path))
307    }
308
309    pub fn delete_file(&self, path: &str) -> Result<()> {
310        if let Some(file) = self.get_file_by_path(path)? {
311            self.conn
312                .execute("DELETE FROM files WHERE id = ?1", params![file.id])?;
313        }
314        Ok(())
315    }
316
317    pub fn insert_symbols(&self, file_id: i64, symbols: &[Symbol]) -> Result<()> {
318        let tx = self.conn.unchecked_transaction()?;
319        tx.execute("DELETE FROM symbols WHERE file_id = ?1", params![file_id])?;
320        for sym in symbols {
321            tx.execute(
322                "INSERT INTO symbols (file_id, name, kind, start_line, end_line, signature, visibility, doc_comment) \
323                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
324                params![
325                    file_id,
326                    sym.name,
327                    sym.kind,
328                    sym.start_line,
329                    sym.end_line,
330                    sym.signature,
331                    sym.visibility,
332                    sym.doc_comment,
333                ],
334            )?;
335        }
336        tx.commit()?;
337        Ok(())
338    }
339
340    pub fn insert_imports(&self, file_id: i64, imports: &[Import]) -> Result<()> {
341        let tx = self.conn.unchecked_transaction()?;
342        tx.execute("DELETE FROM imports WHERE file_id = ?1", params![file_id])?;
343        for import in imports {
344            let imported_names = serde_json::to_string(&import.imported_names)?;
345            tx.execute(
346                "INSERT INTO imports (file_id, source, target, is_relative, imported_names, line) \
347                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
348                params![
349                    file_id,
350                    import.source,
351                    import.target,
352                    if import.is_relative { 1 } else { 0 },
353                    imported_names,
354                    import.line,
355                ],
356            )?;
357        }
358        tx.commit()?;
359        Ok(())
360    }
361
362    pub fn insert_call_sites(&self, file_id: i64, call_sites: &[CallSite]) -> Result<()> {
363        let tx = self.conn.unchecked_transaction()?;
364        tx.execute(
365            "DELETE FROM call_sites WHERE file_id = ?1",
366            params![file_id],
367        )?;
368        for call_site in call_sites {
369            tx.execute(
370                "INSERT INTO call_sites (file_id, caller, callee, line, confidence, resolution_strategy) \
371                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
372                params![
373                    file_id,
374                    call_site.caller,
375                    call_site.callee,
376                    call_site.line,
377                    call_site.confidence,
378                    call_site.resolution_strategy,
379                ],
380            )?;
381        }
382        tx.commit()?;
383        Ok(())
384    }
385
386    pub fn get_symbols_by_file(&self, file_id: i64) -> Result<Vec<Symbol>> {
387        let mut stmt = self.conn.prepare(
388            "SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.signature, s.visibility, s.doc_comment, f.path \
389             FROM symbols s JOIN files f ON s.file_id = f.id WHERE s.file_id = ?1 \
390             ORDER BY s.start_line ASC, s.name ASC",
391        )?;
392        let rows = stmt.query_map(params![file_id], |row| {
393            Ok(Symbol {
394                id: row.get(0)?,
395                file_path: row.get(8)?,
396                name: row.get(1)?,
397                kind: row.get(2)?,
398                start_line: row.get(3)?,
399                end_line: row.get(4)?,
400                signature: row.get(5)?,
401                visibility: row.get(6)?,
402                doc_comment: row.get(7)?,
403            })
404        })?;
405        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
406    }
407
408    pub fn get_imports_by_file(&self, file_id: i64) -> Result<Vec<CachedImport>> {
409        let mut stmt = self.conn.prepare(
410            "SELECT id, file_id, source, target, is_relative, imported_names, line \
411             FROM imports WHERE file_id = ?1 \
412             ORDER BY line ASC, target ASC",
413        )?;
414        let rows = stmt.query_map(params![file_id], |row| {
415            let imported_names_json: String = row.get(5)?;
416            let imported_names = serde_json::from_str(&imported_names_json).unwrap_or_default();
417            Ok(CachedImport {
418                id: row.get(0)?,
419                file_id: row.get(1)?,
420                source: row.get(2)?,
421                target: row.get(3)?,
422                is_relative: row.get::<_, i64>(4)? != 0,
423                imported_names,
424                line: row.get::<_, i64>(6)? as u32,
425            })
426        })?;
427        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
428    }
429
430    pub fn get_all_imports(&self) -> Result<Vec<CachedImport>> {
431        let mut stmt = self.conn.prepare(
432            "SELECT id, file_id, source, target, is_relative, imported_names, line \
433             FROM imports \
434             ORDER BY file_id ASC, line ASC, target ASC",
435        )?;
436        let rows = stmt.query_map([], |row| {
437            let imported_names_json: String = row.get(5)?;
438            let imported_names = serde_json::from_str(&imported_names_json).unwrap_or_default();
439            Ok(CachedImport {
440                id: row.get(0)?,
441                file_id: row.get(1)?,
442                source: row.get(2)?,
443                target: row.get(3)?,
444                is_relative: row.get::<_, i64>(4)? != 0,
445                imported_names,
446                line: row.get::<_, i64>(6)? as u32,
447            })
448        })?;
449        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
450    }
451
452    pub fn get_call_sites_by_file(&self, file_id: i64) -> Result<Vec<CachedCallSite>> {
453        let mut stmt = self.conn.prepare(
454            "SELECT id, file_id, caller, callee, line, confidence, resolution_strategy \
455             FROM call_sites WHERE file_id = ?1 \
456             ORDER BY line ASC, callee ASC",
457        )?;
458        let rows = stmt.query_map(params![file_id], |row| {
459            Ok(CachedCallSite {
460                id: row.get(0)?,
461                file_id: row.get(1)?,
462                caller: row.get(2)?,
463                callee: row.get(3)?,
464                line: row.get::<_, i64>(4)? as u32,
465                confidence: row.get(5)?,
466                resolution_strategy: row.get(6)?,
467            })
468        })?;
469        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
470    }
471
472    pub fn get_all_call_sites(&self) -> Result<Vec<CachedCallSite>> {
473        let mut stmt = self.conn.prepare(
474            "SELECT id, file_id, caller, callee, line, confidence, resolution_strategy \
475             FROM call_sites \
476             ORDER BY file_id ASC, line ASC, callee ASC",
477        )?;
478        let rows = stmt.query_map([], |row| {
479            Ok(CachedCallSite {
480                id: row.get(0)?,
481                file_id: row.get(1)?,
482                caller: row.get(2)?,
483                callee: row.get(3)?,
484                line: row.get::<_, i64>(4)? as u32,
485                confidence: row.get(5)?,
486                resolution_strategy: row.get(6)?,
487            })
488        })?;
489        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
490    }
491
492    pub fn clear_edges_by_kind(&self, kind: EdgeKind) -> Result<()> {
493        self.conn
494            .execute("DELETE FROM edges WHERE kind = ?1", params![kind.as_str()])?;
495        Ok(())
496    }
497
498    pub fn clear_symbol_edges_by_kind(&self, kind: &str) -> Result<()> {
499        self.conn
500            .execute("DELETE FROM symbol_edges WHERE kind = ?1", params![kind])?;
501        Ok(())
502    }
503
504    pub fn delete_edges_by_source(&self, source_file_id: i64, kind: EdgeKind) -> Result<()> {
505        self.conn.execute(
506            "DELETE FROM edges WHERE source_file_id = ?1 AND kind = ?2",
507            params![source_file_id, kind.as_str()],
508        )?;
509        Ok(())
510    }
511
512    pub fn delete_symbol_edges_by_source(&self, source_file_id: i64, kind: &str) -> Result<()> {
513        self.conn.execute(
514            "DELETE FROM symbol_edges WHERE source_file_id = ?1 AND kind = ?2",
515            params![source_file_id, kind],
516        )?;
517        Ok(())
518    }
519
520    pub fn clear_search_index(&self) -> Result<()> {
521        self.conn.execute("DELETE FROM search_index", [])?;
522        Ok(())
523    }
524
525    pub fn clear_search_vectors(&self, backend: Option<&str>) -> Result<()> {
526        match backend {
527            Some(backend) => {
528                self.conn.execute(
529                    "DELETE FROM search_vectors WHERE backend = ?1",
530                    params![backend],
531                )?;
532            }
533            None => {
534                self.conn.execute("DELETE FROM search_vectors", [])?;
535            }
536        }
537        Ok(())
538    }
539
540    pub fn has_search_vector(&self, node_id: i64, backend: &str) -> Result<bool> {
541        let mut stmt = self.conn.prepare(
542            "SELECT 1 FROM search_vectors WHERE node_id = ?1 AND backend = ?2 LIMIT 1",
543        )?;
544        let mut rows = stmt.query_map(params![node_id, backend], |row| row.get::<_, i64>(0))?;
545        match rows.next() {
546            Some(Ok(_)) => Ok(true),
547            Some(Err(error)) => Err(error.into()),
548            None => Ok(false),
549        }
550    }
551
552    pub fn prune_search_vectors_to_index(&self, backend: &str) -> Result<()> {
553        self.conn.execute(
554            "DELETE FROM search_vectors \
555             WHERE backend = ?1 \
556             AND node_id NOT IN (SELECT CAST(node_id AS INTEGER) FROM search_index)",
557            params![backend],
558        )?;
559        Ok(())
560    }
561
562    pub fn insert_edge(
563        &self,
564        source_file_id: i64,
565        target_file_id: Option<i64>,
566        target_path: Option<&str>,
567        kind: EdgeKind,
568        confidence: f64,
569    ) -> Result<()> {
570        self.conn.execute(
571            "INSERT INTO edges (source_file_id, target_file_id, target_path, kind, confidence) \
572             VALUES (?1, ?2, ?3, ?4, ?5)",
573            params![
574                source_file_id,
575                target_file_id,
576                target_path,
577                kind.as_str(),
578                confidence,
579            ],
580        )?;
581        Ok(())
582    }
583
584    pub fn get_edges_by_kind(&self, kind: EdgeKind) -> Result<Vec<CachedEdge>> {
585        let mut stmt = self.conn.prepare(
586            "SELECT id, source_file_id, target_file_id, target_path, kind, confidence \
587             FROM edges WHERE kind = ?1 \
588             ORDER BY source_file_id ASC, target_file_id ASC, target_path ASC",
589        )?;
590        let rows = stmt.query_map(params![kind.as_str()], |row| {
591            Ok(CachedEdge {
592                id: row.get(0)?,
593                source_file_id: row.get(1)?,
594                target_file_id: row.get(2)?,
595                target_path: row.get(3)?,
596                kind: row.get(4)?,
597                confidence: row.get(5)?,
598            })
599        })?;
600        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
601    }
602
603    pub fn insert_symbol_edges(&self, edges: &[CachedSymbolEdge]) -> Result<()> {
604        if edges.is_empty() {
605            return Ok(());
606        }
607        let tx = self.conn.unchecked_transaction()?;
608        for edge in edges {
609            tx.execute(
610                "INSERT INTO symbol_edges (source_symbol_id, target_symbol_id, source_file_id, target_file_id, kind, line, confidence, resolution_strategy) \
611                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
612                params![
613                    edge.source_symbol_id,
614                    edge.target_symbol_id,
615                    edge.source_file_id,
616                    edge.target_file_id,
617                    edge.kind,
618                    edge.line,
619                    edge.confidence,
620                    edge.resolution_strategy,
621                ],
622            )?;
623        }
624        tx.commit()?;
625        Ok(())
626    }
627
628    pub fn get_symbol_edges_by_kind(&self, kind: &str) -> Result<Vec<CachedSymbolEdge>> {
629        let mut stmt = self.conn.prepare(
630            "SELECT id, source_symbol_id, target_symbol_id, source_file_id, target_file_id, kind, line, confidence, resolution_strategy \
631             FROM symbol_edges WHERE kind = ?1 \
632             ORDER BY source_symbol_id ASC, target_symbol_id ASC, line ASC",
633        )?;
634        let rows = stmt.query_map(params![kind], |row| {
635            Ok(CachedSymbolEdge {
636                id: row.get(0)?,
637                source_symbol_id: row.get(1)?,
638                target_symbol_id: row.get(2)?,
639                source_file_id: row.get(3)?,
640                target_file_id: row.get(4)?,
641                kind: row.get(5)?,
642                line: row.get::<_, i64>(6)? as u32,
643                confidence: row.get(7)?,
644                resolution_strategy: row.get(8)?,
645            })
646        })?;
647        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
648    }
649
650    pub fn insert_search_document(
651        &self,
652        node_id: i64,
653        file_path: &str,
654        kind: &str,
655        name: &str,
656        signature: Option<&str>,
657        doc_comment: Option<&str>,
658        imports: &str,
659        content: &str,
660    ) -> Result<()> {
661        self.conn.execute(
662            "INSERT INTO search_index (node_id, file_path, kind, name, signature, doc_comment, imports, content) \
663             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
664            params![
665                node_id,
666                file_path,
667                kind,
668                name,
669                signature,
670                doc_comment,
671                imports,
672                content,
673            ],
674        )?;
675        Ok(())
676    }
677
678    pub fn insert_search_vector(
679        &self,
680        node_id: i64,
681        file_path: &str,
682        kind: &str,
683        name: &str,
684        signature: Option<&str>,
685        backend: &str,
686        vector: &[f32],
687    ) -> Result<()> {
688        let mut bytes = Vec::with_capacity(vector.len() * std::mem::size_of::<f32>());
689        for value in vector {
690            bytes.extend_from_slice(&value.to_le_bytes());
691        }
692        self.conn.execute(
693            "INSERT OR REPLACE INTO search_vectors \
694             (node_id, file_path, kind, name, signature, backend, dims, vector) \
695             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
696            params![
697                node_id,
698                file_path,
699                kind,
700                name,
701                signature,
702                backend,
703                i64::try_from(vector.len()).unwrap_or(0),
704                bytes,
705            ],
706        )?;
707        Ok(())
708    }
709
710    pub fn search_nodes_fts(
711        &self,
712        query: &str,
713        kind: Option<&str>,
714        limit: usize,
715    ) -> Result<Vec<CachedSearchHit>> {
716        let limit = i64::try_from(limit).unwrap_or(50);
717        let hits = if let Some(kind) = kind {
718            let mut stmt = self.conn.prepare(
719                "SELECT node_id, file_path, kind, name, signature, bm25(search_index) \
720                 FROM search_index \
721                 WHERE search_index MATCH ?1 AND kind = ?2 \
722                 ORDER BY bm25(search_index) \
723                 LIMIT ?3",
724            )?;
725            let rows = stmt.query_map(params![query, kind, limit], |row| {
726                let score: f64 = row.get(5)?;
727                Ok(CachedSearchHit {
728                    node_id: row.get(0)?,
729                    file_path: row.get(1)?,
730                    kind: row.get(2)?,
731                    name: row.get(3)?,
732                    signature: row.get(4)?,
733                    score: -score,
734                    match_reason: "fts5".to_string(),
735                })
736            })?;
737            rows.collect::<std::result::Result<Vec<_>, _>>()?
738        } else {
739            let mut stmt = self.conn.prepare(
740                "SELECT node_id, file_path, kind, name, signature, bm25(search_index) \
741                 FROM search_index \
742                 WHERE search_index MATCH ?1 \
743                 ORDER BY bm25(search_index) \
744                 LIMIT ?2",
745            )?;
746            let rows = stmt.query_map(params![query, limit], |row| {
747                let score: f64 = row.get(5)?;
748                Ok(CachedSearchHit {
749                    node_id: row.get(0)?,
750                    file_path: row.get(1)?,
751                    kind: row.get(2)?,
752                    name: row.get(3)?,
753                    signature: row.get(4)?,
754                    score: -score,
755                    match_reason: "fts5".to_string(),
756                })
757            })?;
758            rows.collect::<std::result::Result<Vec<_>, _>>()?
759        };
760        Ok(hits)
761    }
762
763    pub fn get_search_vectors(
764        &self,
765        backend: &str,
766        kind: Option<&str>,
767    ) -> Result<Vec<CachedSearchVector>> {
768        let query = match kind {
769            Some(_) => {
770                "SELECT node_id, file_path, kind, name, signature, backend, dims, vector \
771                 FROM search_vectors WHERE backend = ?1 AND kind = ?2"
772            }
773            None => {
774                "SELECT node_id, file_path, kind, name, signature, backend, dims, vector \
775                 FROM search_vectors WHERE backend = ?1"
776            }
777        };
778        let mut stmt = self.conn.prepare(query)?;
779        let map_row = |row: &rusqlite::Row<'_>| {
780            let blob: Vec<u8> = row.get(7)?;
781            let mut vector = Vec::with_capacity(blob.len() / 4);
782            for chunk in blob.chunks_exact(4) {
783                vector.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
784            }
785            Ok(CachedSearchVector {
786                node_id: row.get(0)?,
787                file_path: row.get(1)?,
788                kind: row.get(2)?,
789                name: row.get(3)?,
790                signature: row.get(4)?,
791                backend: row.get(5)?,
792                dims: row.get::<_, i64>(6)? as u32,
793                vector,
794            })
795        };
796        let rows = match kind {
797            Some(kind) => stmt.query_map(params![backend, kind], map_row)?,
798            None => stmt.query_map(params![backend], map_row)?,
799        };
800        Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
801    }
802
803    pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
804        self.conn.execute(
805            "INSERT INTO config (key, value) VALUES (?1, ?2) \
806             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
807            params![key, value],
808        )?;
809        Ok(())
810    }
811
812    pub fn get_config(&self, key: &str) -> Result<Option<String>> {
813        let mut stmt = self
814            .conn
815            .prepare("SELECT value FROM config WHERE key = ?1")?;
816        let mut rows = stmt.query_map(params![key], |row| row.get(0))?;
817        match rows.next() {
818            Some(Ok(val)) => Ok(Some(val)),
819            Some(Err(e)) => Err(e.into()),
820            None => Ok(None),
821        }
822    }
823
824    pub fn file_count(&self) -> Result<i64> {
825        let count: i64 = self
826            .conn
827            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
828        Ok(count)
829    }
830
831    pub fn symbol_count(&self) -> Result<i64> {
832        let count: i64 = self
833            .conn
834            .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))?;
835        Ok(count)
836    }
837
838    pub fn import_count(&self) -> Result<i64> {
839        let count: i64 = self
840            .conn
841            .query_row("SELECT COUNT(*) FROM imports", [], |row| row.get(0))?;
842        Ok(count)
843    }
844
845    pub fn call_site_count(&self) -> Result<i64> {
846        let count: i64 = self
847            .conn
848            .query_row("SELECT COUNT(*) FROM call_sites", [], |row| row.get(0))?;
849        Ok(count)
850    }
851
852    pub fn symbol_edge_count(&self) -> Result<i64> {
853        let count: i64 = self
854            .conn
855            .query_row("SELECT COUNT(*) FROM symbol_edges", [], |row| row.get(0))?;
856        Ok(count)
857    }
858
859    pub fn edge_count(&self) -> Result<i64> {
860        let count: i64 = self
861            .conn
862            .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
863        Ok(count)
864    }
865
866    pub fn get_all_files(&self) -> Result<Vec<CachedFile>> {
867        let mut stmt = self.conn.prepare(
868            "SELECT id, path, language, hash, size_bytes, loc, last_indexed_at \
869             FROM files \
870             ORDER BY path ASC",
871        )?;
872        let rows = stmt.query_map([], |row| {
873            Ok(CachedFile {
874                id: row.get(0)?,
875                path: row.get(1)?,
876                language: row.get(2)?,
877                hash: row.get(3)?,
878                size_bytes: row.get(4)?,
879                loc: row.get(5)?,
880                last_indexed_at: row.get(6)?,
881            })
882        })?;
883        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
884    }
885
886    pub fn language_stats(&self) -> Result<Vec<(String, i64)>> {
887        let mut stmt = self.conn.prepare(
888            "SELECT language, COUNT(*) as cnt \
889             FROM files \
890             WHERE language != '' \
891             GROUP BY language \
892             ORDER BY cnt DESC",
893        )?;
894        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
895        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
896    }
897}