Skip to main content

open_kioku_storage_sqlite/
lib.rs

1use open_kioku_core::{
2    CodeChunk, File, FileId, GraphEdge, GraphNode, Import, IndexManifest, Symbol, SymbolId,
3    SymbolOccurrence, TestTarget,
4};
5use open_kioku_errors::{OkError, Result};
6use open_kioku_storage::{GraphStore, IndexData, MetadataStore};
7use rusqlite::{params, Connection, OptionalExtension};
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10
11pub struct SqliteStore {
12    path: PathBuf,
13    connection: Mutex<Connection>,
14}
15
16impl SqliteStore {
17    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
18        let path = path.as_ref().to_path_buf();
19        if let Some(parent) = path.parent() {
20            std::fs::create_dir_all(parent)?;
21        }
22        let connection = Connection::open_with_flags(
23            &path,
24            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
25                | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
26                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
27        )
28        .map_err(storage_err)?;
29        let store = Self {
30            path,
31            connection: Mutex::new(connection),
32        };
33        store.initialize()?;
34        Ok(store)
35    }
36
37    pub fn path(&self) -> &Path {
38        &self.path
39    }
40}
41
42impl MetadataStore for SqliteStore {
43    fn initialize(&self) -> Result<()> {
44        let conn = self
45            .connection
46            .lock()
47            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
48        conn.execute_batch(
49            r#"
50            PRAGMA journal_mode = WAL;
51            CREATE TABLE IF NOT EXISTS manifests (
52              id INTEGER PRIMARY KEY CHECK (id = 1),
53              json TEXT NOT NULL
54            );
55            CREATE TABLE IF NOT EXISTS files (
56              id TEXT PRIMARY KEY,
57              path TEXT NOT NULL UNIQUE,
58              json TEXT NOT NULL
59            );
60            CREATE TABLE IF NOT EXISTS symbols (
61              id TEXT PRIMARY KEY,
62              name TEXT NOT NULL,
63              qualified_name TEXT NOT NULL,
64              file_id TEXT NOT NULL,
65              json TEXT NOT NULL
66            );
67            CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
68            CREATE TABLE IF NOT EXISTS chunks (
69              id TEXT PRIMARY KEY,
70              file_id TEXT NOT NULL,
71              start_line INTEGER NOT NULL,
72              end_line INTEGER NOT NULL,
73              text TEXT NOT NULL,
74              json TEXT NOT NULL
75            );
76            CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
77            CREATE TABLE IF NOT EXISTS tests (
78              id TEXT PRIMARY KEY,
79              file_id TEXT NOT NULL,
80              json TEXT NOT NULL
81            );
82            CREATE INDEX IF NOT EXISTS idx_tests_file ON tests(file_id);
83            CREATE TABLE IF NOT EXISTS imports (
84              id TEXT PRIMARY KEY,
85              file_id TEXT NOT NULL,
86              imported TEXT NOT NULL,
87              json TEXT NOT NULL
88            );
89            CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file_id);
90            CREATE TABLE IF NOT EXISTS occurrences (
91              id TEXT PRIMARY KEY,
92              symbol_id TEXT NOT NULL,
93              file_id TEXT NOT NULL,
94              is_definition INTEGER NOT NULL,
95              json TEXT NOT NULL
96            );
97            CREATE INDEX IF NOT EXISTS idx_occurrences_symbol ON occurrences(symbol_id);
98            CREATE INDEX IF NOT EXISTS idx_occurrences_file ON occurrences(file_id);
99            CREATE TABLE IF NOT EXISTS graph_nodes (
100              id TEXT PRIMARY KEY,
101              label TEXT NOT NULL,
102              json TEXT NOT NULL
103            );
104            CREATE TABLE IF NOT EXISTS graph_edges (
105              id TEXT PRIMARY KEY,
106              from_id TEXT NOT NULL,
107              to_id TEXT NOT NULL,
108              edge_type TEXT NOT NULL,
109              json TEXT NOT NULL
110            );
111            CREATE INDEX IF NOT EXISTS idx_graph_edges_from ON graph_edges(from_id);
112            CREATE INDEX IF NOT EXISTS idx_graph_edges_to ON graph_edges(to_id);
113            "#,
114        )
115        .map_err(storage_err)?;
116        Ok(())
117    }
118
119    fn put_manifest(&self, manifest: &IndexManifest) -> Result<()> {
120        let conn = self
121            .connection
122            .lock()
123            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
124        let json = serde_json::to_string(manifest)?;
125        conn.execute(
126            "INSERT INTO manifests(id, json) VALUES(1, ?1) ON CONFLICT(id) DO UPDATE SET json = excluded.json",
127            params![json],
128        )
129        .map_err(storage_err)?;
130        Ok(())
131    }
132
133    fn manifest(&self) -> Result<Option<IndexManifest>> {
134        let conn = self
135            .connection
136            .lock()
137            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
138        let raw: Option<String> = conn
139            .query_row("SELECT json FROM manifests WHERE id = 1", [], |row| {
140                row.get(0)
141            })
142            .optional()
143            .map_err(storage_err)?;
144        raw.map(|json| serde_json::from_str(&json).map_err(Into::into))
145            .transpose()
146    }
147
148    fn replace_index(&self, data: IndexData<'_>) -> Result<()> {
149        let mut conn = self
150            .connection
151            .lock()
152            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
153        let tx = conn.transaction().map_err(storage_err)?;
154        tx.execute("DELETE FROM occurrences", [])
155            .map_err(storage_err)?;
156        tx.execute("DELETE FROM imports", []).map_err(storage_err)?;
157        tx.execute("DELETE FROM tests", []).map_err(storage_err)?;
158        tx.execute("DELETE FROM chunks", []).map_err(storage_err)?;
159        tx.execute("DELETE FROM symbols", []).map_err(storage_err)?;
160        tx.execute("DELETE FROM files", []).map_err(storage_err)?;
161        tx.execute("DELETE FROM manifests", [])
162            .map_err(storage_err)?;
163        tx.execute(
164            "INSERT INTO manifests(id, json) VALUES(1, ?1)",
165            params![serde_json::to_string(data.manifest)?],
166        )
167        .map_err(storage_err)?;
168        for file in data.files {
169            tx.execute(
170                "INSERT INTO files(id, path, json) VALUES(?1, ?2, ?3)",
171                params![
172                    &file.id.0,
173                    file.path.to_string_lossy().as_ref(),
174                    serde_json::to_string(file)?
175                ],
176            )
177            .map_err(storage_err)?;
178        }
179        for symbol in data.symbols {
180            tx.execute(
181                "INSERT INTO symbols(id, name, qualified_name, file_id, json) VALUES(?1, ?2, ?3, ?4, ?5)",
182                params![
183                    &symbol.id.0,
184                    &symbol.name,
185                    &symbol.qualified_name,
186                    &symbol.file_id.0,
187                    serde_json::to_string(symbol)?
188                ],
189            )
190            .map_err(storage_err)?;
191        }
192        for chunk in data.chunks {
193            tx.execute(
194                "INSERT INTO chunks(id, file_id, start_line, end_line, text, json) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
195                params![
196                    &chunk.id,
197                    &chunk.file_id.0,
198                    chunk.range.start,
199                    chunk.range.end,
200                    &chunk.text,
201                    serde_json::to_string(chunk)?
202                ],
203            )
204            .map_err(storage_err)?;
205        }
206        for test in data.tests {
207            tx.execute(
208                "INSERT INTO tests(id, file_id, json) VALUES(?1, ?2, ?3) ON CONFLICT(id) DO UPDATE SET json = excluded.json",
209                params![&test.id, &test.file_id.0, serde_json::to_string(test)?],
210            )
211            .map_err(storage_err)?;
212        }
213        for import in data.imports {
214            tx.execute(
215                "INSERT INTO imports(id, file_id, imported, json) VALUES(?1, ?2, ?3, ?4)",
216                params![
217                    occurrence_id(
218                        &import.file_id.0,
219                        &import.imported,
220                        import.range.as_ref().map(|range| range.start),
221                        true
222                    ),
223                    &import.file_id.0,
224                    &import.imported,
225                    serde_json::to_string(import)?
226                ],
227            )
228            .map_err(storage_err)?;
229        }
230        for occurrence in data.occurrences {
231            tx.execute(
232                "INSERT INTO occurrences(id, symbol_id, file_id, is_definition, json) VALUES(?1, ?2, ?3, ?4, ?5)",
233                params![
234                    occurrence_id(
235                        &occurrence.file_id.0,
236                        &occurrence.symbol_id.0,
237                        occurrence.range.as_ref().map(|range| range.start),
238                        occurrence.is_definition,
239                    ),
240                    &occurrence.symbol_id.0,
241                    &occurrence.file_id.0,
242                    if occurrence.is_definition { 1 } else { 0 },
243                    serde_json::to_string(occurrence)?
244                ],
245            )
246            .map_err(storage_err)?;
247        }
248        tx.commit().map_err(storage_err)?;
249        Ok(())
250    }
251
252    fn list_files(&self, limit: usize, offset: usize) -> Result<Vec<File>> {
253        let conn = self
254            .connection
255            .lock()
256            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
257        let mut stmt = conn
258            .prepare("SELECT json FROM files ORDER BY path LIMIT ?1 OFFSET ?2")
259            .map_err(storage_err)?;
260        let rows = stmt
261            .query_map(params![limit as i64, offset as i64], |row| {
262                row.get::<_, String>(0)
263            })
264            .map_err(storage_err)?;
265        collect_json(rows)
266    }
267
268    fn get_file_by_path(&self, path: &Path) -> Result<Option<File>> {
269        let conn = self
270            .connection
271            .lock()
272            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
273        let raw: Option<String> = conn
274            .query_row(
275                "SELECT json FROM files WHERE path = ?1",
276                params![path.to_string_lossy().as_ref()],
277                |row| row.get(0),
278            )
279            .optional()
280            .map_err(storage_err)?;
281        raw.map(|json| serde_json::from_str(&json).map_err(Into::into))
282            .transpose()
283    }
284
285    fn list_symbols(
286        &self,
287        query: Option<&str>,
288        limit: usize,
289        offset: usize,
290    ) -> Result<Vec<Symbol>> {
291        let conn = self
292            .connection
293            .lock()
294            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
295        let pattern = format!("%{}%", query.unwrap_or_default());
296        let mut stmt = conn
297            .prepare(
298                "SELECT json FROM symbols WHERE (?1 = '%%' OR name LIKE ?1 COLLATE NOCASE OR qualified_name LIKE ?1 COLLATE NOCASE) ORDER BY qualified_name LIMIT ?2 OFFSET ?3",
299            )
300            .map_err(storage_err)?;
301        let rows = stmt
302            .query_map(params![pattern, limit as i64, offset as i64], |row| {
303                row.get::<_, String>(0)
304            })
305            .map_err(storage_err)?;
306        collect_json(rows)
307    }
308
309    fn symbol_by_id(&self, id: &SymbolId) -> Result<Option<Symbol>> {
310        let conn = self
311            .connection
312            .lock()
313            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
314        let raw: Option<String> = conn
315            .query_row(
316                "SELECT json FROM symbols WHERE id = ?1",
317                params![&id.0],
318                |row| row.get(0),
319            )
320            .optional()
321            .map_err(storage_err)?;
322        raw.map(|json| serde_json::from_str(&json).map_err(Into::into))
323            .transpose()
324    }
325
326    fn chunks_for_file(&self, file_id: &FileId) -> Result<Vec<CodeChunk>> {
327        let conn = self
328            .connection
329            .lock()
330            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
331        let mut stmt = conn
332            .prepare("SELECT json FROM chunks WHERE file_id = ?1 ORDER BY start_line")
333            .map_err(storage_err)?;
334        let rows = stmt
335            .query_map(params![&file_id.0], |row| row.get::<_, String>(0))
336            .map_err(storage_err)?;
337        collect_json(rows)
338    }
339
340    fn all_chunks(&self) -> Result<Vec<CodeChunk>> {
341        let conn = self
342            .connection
343            .lock()
344            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
345        let mut stmt = conn
346            .prepare("SELECT json FROM chunks ORDER BY file_id, start_line")
347            .map_err(storage_err)?;
348        let rows = stmt
349            .query_map([], |row| row.get::<_, String>(0))
350            .map_err(storage_err)?;
351        collect_json(rows)
352    }
353
354    fn tests(&self) -> Result<Vec<TestTarget>> {
355        let conn = self
356            .connection
357            .lock()
358            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
359        let mut stmt = conn
360            .prepare("SELECT json FROM tests ORDER BY file_id")
361            .map_err(storage_err)?;
362        let rows = stmt
363            .query_map([], |row| row.get::<_, String>(0))
364            .map_err(storage_err)?;
365        collect_json(rows)
366    }
367
368    fn imports(&self) -> Result<Vec<Import>> {
369        let conn = self
370            .connection
371            .lock()
372            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
373        let mut stmt = conn
374            .prepare("SELECT json FROM imports ORDER BY file_id")
375            .map_err(storage_err)?;
376        let rows = stmt
377            .query_map([], |row| row.get::<_, String>(0))
378            .map_err(storage_err)?;
379        collect_json(rows)
380    }
381
382    fn references_for_symbol(&self, id: &SymbolId, limit: usize) -> Result<Vec<SymbolOccurrence>> {
383        let conn = self
384            .connection
385            .lock()
386            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
387        let mut stmt = conn
388            .prepare(
389                "SELECT json FROM occurrences WHERE symbol_id = ?1 AND is_definition = 0 ORDER BY file_id LIMIT ?2",
390            )
391            .map_err(storage_err)?;
392        let rows = stmt
393            .query_map(params![&id.0, limit as i64], |row| row.get::<_, String>(0))
394            .map_err(storage_err)?;
395        collect_json(rows)
396    }
397
398    fn occurrences_for_file(&self, file_id: &FileId) -> Result<Vec<SymbolOccurrence>> {
399        let conn = self
400            .connection
401            .lock()
402            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
403        let mut stmt = conn
404            .prepare("SELECT json FROM occurrences WHERE file_id = ?1 ORDER BY symbol_id")
405            .map_err(storage_err)?;
406        let rows = stmt
407            .query_map(params![&file_id.0], |row| row.get::<_, String>(0))
408            .map_err(storage_err)?;
409        collect_json(rows)
410    }
411
412    fn symbols_for_file(&self, file_id: &FileId) -> Result<Vec<Symbol>> {
413        let conn = self
414            .connection
415            .lock()
416            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
417        let mut stmt = conn
418            .prepare("SELECT json FROM symbols WHERE file_id = ?1 ORDER BY name")
419            .map_err(storage_err)?;
420        let rows = stmt
421            .query_map(params![&file_id.0], |row| row.get::<_, String>(0))
422            .map_err(storage_err)?;
423        collect_json(rows)
424    }
425
426    fn find_chunks_containing(&self, query: &str, limit: usize) -> Result<Vec<CodeChunk>> {
427        let conn = self
428            .connection
429            .lock()
430            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
431        let pattern = format!("%{}%", query);
432        let mut stmt = conn
433            .prepare("SELECT json FROM chunks WHERE text LIKE ?1 LIMIT ?2")
434            .map_err(storage_err)?;
435        let rows = stmt
436            .query_map(params![pattern, limit as i64], |row| {
437                row.get::<_, String>(0)
438            })
439            .map_err(storage_err)?;
440        collect_json(rows)
441    }
442
443    fn find_files_by_path_pattern(&self, pattern: &str) -> Result<Vec<File>> {
444        let conn = self
445            .connection
446            .lock()
447            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
448        let match_pat = format!("%{}%", pattern);
449        let mut stmt = conn
450            .prepare("SELECT json FROM files WHERE path LIKE ?1 COLLATE NOCASE")
451            .map_err(storage_err)?;
452        let rows = stmt
453            .query_map(params![match_pat], |row| row.get::<_, String>(0))
454            .map_err(storage_err)?;
455        collect_json(rows)
456    }
457
458    fn tests_for_files(&self, file_ids: &[FileId]) -> Result<Vec<TestTarget>> {
459        if file_ids.is_empty() {
460            return Ok(Vec::new());
461        }
462        let conn = self
463            .connection
464            .lock()
465            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
466
467        let placeholders = file_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
468        let sql = format!("SELECT json FROM tests WHERE file_id IN ({})", placeholders);
469        let mut stmt = conn.prepare(&sql).map_err(storage_err)?;
470
471        let params = rusqlite::params_from_iter(file_ids.iter().map(|id| &id.0));
472        let rows = stmt
473            .query_map(params, |row| row.get::<_, String>(0))
474            .map_err(storage_err)?;
475        collect_json(rows)
476    }
477}
478
479impl GraphStore for SqliteStore {
480    fn replace_graph(&self, nodes: &[GraphNode], edges: &[GraphEdge]) -> Result<()> {
481        let mut conn = self
482            .connection
483            .lock()
484            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
485        let tx = conn.transaction().map_err(storage_err)?;
486        tx.execute("DELETE FROM graph_edges", [])
487            .map_err(storage_err)?;
488        tx.execute("DELETE FROM graph_nodes", [])
489            .map_err(storage_err)?;
490        for node in nodes {
491            tx.execute(
492                "INSERT INTO graph_nodes(id, label, json) VALUES(?1, ?2, ?3)",
493                params![&node.id.0, &node.label, serde_json::to_string(node)?],
494            )
495            .map_err(storage_err)?;
496        }
497        for edge in edges {
498            tx.execute(
499                "INSERT INTO graph_edges(id, from_id, to_id, edge_type, json) VALUES(?1, ?2, ?3, ?4, ?5)",
500                params![
501                    &edge.id.0,
502                    &edge.from.0,
503                    &edge.to.0,
504                    format!("{:?}", edge.edge_type),
505                    serde_json::to_string(edge)?
506                ],
507            )
508            .map_err(storage_err)?;
509        }
510        tx.commit().map_err(storage_err)?;
511        Ok(())
512    }
513
514    fn neighbors(&self, node: &str, limit: usize) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)> {
515        let conn = self
516            .connection
517            .lock()
518            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
519        let mut stmt = conn
520            .prepare("SELECT json FROM graph_edges WHERE from_id = ?1 OR to_id = ?1 LIMIT ?2")
521            .map_err(storage_err)?;
522        let rows = stmt
523            .query_map(params![node, limit as i64], |row| row.get::<_, String>(0))
524            .map_err(storage_err)?;
525        let edges: Vec<GraphEdge> = collect_json(rows)?;
526        let mut ids = edges
527            .iter()
528            .flat_map(|edge| [edge.from.0.clone(), edge.to.0.clone()])
529            .collect::<Vec<_>>();
530        ids.sort();
531        ids.dedup();
532        let mut nodes = Vec::new();
533        for id in ids {
534            if let Some(node) = graph_node_by_id(&conn, &id)? {
535                nodes.push(node);
536            }
537        }
538        Ok((nodes, edges))
539    }
540
541    fn shortest_path(&self, from: &str, to: &str, max_depth: usize) -> Result<Vec<GraphEdge>> {
542        use std::collections::{HashSet, VecDeque};
543
544        let conn = self
545            .connection
546            .lock()
547            .map_err(|_| OkError::Storage("sqlite mutex poisoned".into()))?;
548
549        // Prepare the statement once outside the BFS loop to avoid
550        // O(N) statement recompilation on large graphs.
551        let mut edge_stmt = conn
552            .prepare("SELECT json FROM graph_edges WHERE from_id = ?1")
553            .map_err(storage_err)?;
554
555        let mut queue = VecDeque::from([(from.to_string(), Vec::<GraphEdge>::new())]);
556        let mut seen = HashSet::new();
557        while let Some((node, path)) = queue.pop_front() {
558            if node == to {
559                return Ok(path);
560            }
561            if path.len() >= max_depth || !seen.insert(node.clone()) {
562                continue;
563            }
564            let rows = edge_stmt
565                .query_map(params![&node], |row| row.get::<_, String>(0))
566                .map_err(storage_err)?;
567            let edges: Vec<GraphEdge> = collect_json(rows)?;
568            for edge in edges {
569                let mut next_path = path.clone();
570                next_path.push(edge.clone());
571                queue.push_back((edge.to.0.clone(), next_path));
572            }
573        }
574        Ok(Vec::new())
575    }
576}
577
578fn collect_json<T, F>(rows: rusqlite::MappedRows<'_, F>) -> Result<Vec<T>>
579where
580    F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<String>,
581    T: serde::de::DeserializeOwned,
582{
583    let mut out = Vec::new();
584    for row in rows {
585        let raw = row.map_err(storage_err)?;
586        out.push(serde_json::from_str(&raw)?);
587    }
588    Ok(out)
589}
590
591fn graph_node_by_id(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
592    let raw: Option<String> = conn
593        .query_row(
594            "SELECT json FROM graph_nodes WHERE id = ?1",
595            params![id],
596            |row| row.get(0),
597        )
598        .optional()
599        .map_err(storage_err)?;
600    raw.map(|json| serde_json::from_str(&json).map_err(Into::into))
601        .transpose()
602}
603
604fn storage_err(err: rusqlite::Error) -> OkError {
605    OkError::Storage(err.to_string())
606}
607
608fn occurrence_id(file_id: &str, value: &str, line: Option<u32>, flag: bool) -> String {
609    use sha2::{Digest, Sha256};
610    let mut hasher = Sha256::new();
611    hasher.update(file_id.as_bytes());
612    hasher.update(b":");
613    hasher.update(value.as_bytes());
614    hasher.update(b":");
615    hasher.update(line.unwrap_or_default().to_string().as_bytes());
616    hasher.update(b":");
617    hasher.update(if flag { b"1" } else { b"0" });
618    format!("{:x}", hasher.finalize())
619}
620
621#[cfg(test)]
622mod tests {
623    use super::SqliteStore;
624    use chrono::Utc;
625    use open_kioku_core::{
626        Confidence, EdgeId, Evidence, EvidenceId, EvidenceSourceType, File, FileId, GraphEdge,
627        GraphEdgeType, GraphNode, GraphNodeType, IndexManifest, IndexQuality, Language, LineRange,
628        NodeId, Repository, RepositoryId, Symbol, SymbolId, SymbolKind,
629    };
630    use open_kioku_storage::{GraphStore, IndexData, MetadataStore};
631
632    fn make_store() -> SqliteStore {
633        SqliteStore::open(":memory:").expect("in-memory store")
634    }
635
636    fn make_file(id: &str, path: &str) -> File {
637        File {
638            id: FileId::new(id),
639            repository_id: RepositoryId::new("repo"),
640            path: path.into(),
641            language: Language::Rust,
642            size_bytes: 100,
643            content_hash: format!("hash-{id}"),
644            is_generated: false,
645            is_vendor: false,
646        }
647    }
648
649    fn make_symbol(id: &str, name: &str, file_id: &str) -> Symbol {
650        Symbol {
651            id: SymbolId::new(id),
652            name: name.into(),
653            qualified_name: format!("module::{name}"),
654            kind: SymbolKind::Function,
655            file_id: FileId::new(file_id),
656            range: Some(LineRange::single(1)),
657            language: Language::Rust,
658            confidence: Confidence::High,
659            provenance: EvidenceSourceType::TreeSitter,
660        }
661    }
662
663    fn evidence() -> Evidence {
664        Evidence {
665            id: EvidenceId::new("ev-1"),
666            source: "test".into(),
667            source_type: EvidenceSourceType::Lexical,
668            file_range: None,
669            symbol_id: None,
670            confidence: Confidence::Medium,
671            message: "test evidence".into(),
672            indexed_at: Utc::now(),
673        }
674    }
675
676    fn make_manifest() -> IndexManifest {
677        IndexManifest {
678            repository: Repository {
679                id: RepositoryId::new("repo"),
680                name: "repo".into(),
681                root: std::path::PathBuf::from("."),
682                branch: None,
683                commit: None,
684                indexed_at: None,
685            },
686            file_count: 2,
687            symbol_count: 2,
688            chunk_count: 0,
689            indexed_at: Utc::now(),
690            schema_version: 1,
691            quality: IndexQuality::default(),
692        }
693    }
694
695    #[test]
696    fn replace_index_and_list_files() {
697        let store = make_store();
698        let file1 = make_file("f1", "src/main.rs");
699        let file2 = make_file("f2", "src/lib.rs");
700        let sym1 = make_symbol("s1", "main_fn", "f1");
701
702        let manifest = make_manifest();
703        let files = vec![file1.clone(), file2.clone()];
704        let symbols = vec![sym1.clone()];
705
706        let data = IndexData {
707            manifest: &manifest,
708            files: &files,
709            symbols: &symbols,
710            occurrences: &[],
711            chunks: &[],
712            imports: &[],
713            tests: &[],
714        };
715        store.replace_index(data).unwrap();
716
717        let files_list = store.list_files(100, 0).unwrap();
718        assert_eq!(files_list.len(), 2);
719
720        let by_path = store
721            .get_file_by_path(&std::path::PathBuf::from("src/main.rs"))
722            .unwrap();
723        assert!(by_path.is_some());
724        assert_eq!(by_path.unwrap().id, file1.id);
725    }
726
727    #[test]
728    fn list_symbols_with_filter() {
729        let store = make_store();
730        let file = make_file("f1", "src/lib.rs");
731        let sym_a = make_symbol("s1", "alpha_handler", "f1");
732        let sym_b = make_symbol("s2", "beta_worker", "f1");
733        let manifest = make_manifest();
734        let files = vec![file];
735        let symbols = vec![sym_a, sym_b];
736        let data = IndexData {
737            manifest: &manifest,
738            files: &files,
739            symbols: &symbols,
740            occurrences: &[],
741            chunks: &[],
742            imports: &[],
743            tests: &[],
744        };
745        store.replace_index(data).unwrap();
746
747        let all = store.list_symbols(None, 100, 0).unwrap();
748        assert_eq!(all.len(), 2);
749
750        let filtered = store.list_symbols(Some("alpha"), 10, 0).unwrap();
751        assert_eq!(filtered.len(), 1);
752        assert_eq!(filtered[0].name, "alpha_handler");
753    }
754
755    #[test]
756    fn replace_graph_and_neighbors() {
757        let store = make_store();
758        // First we need an index so that the graph tables exist.
759        let file = make_file("f1", "src/lib.rs");
760        let manifest = make_manifest();
761        let files = vec![file];
762        let data = IndexData {
763            manifest: &manifest,
764            files: &files,
765            symbols: &[],
766            occurrences: &[],
767            chunks: &[],
768            imports: &[],
769            tests: &[],
770        };
771        store.replace_index(data).unwrap();
772
773        let node_a = GraphNode {
774            id: NodeId::new("file:src/lib.rs"),
775            node_type: GraphNodeType::File,
776            label: "src/lib.rs".into(),
777            file_id: Some(FileId::new("f1")),
778            symbol_id: None,
779        };
780        let node_b = GraphNode {
781            id: NodeId::new("symbol:s1"),
782            node_type: GraphNodeType::Function,
783            label: "worker".into(),
784            file_id: Some(FileId::new("f1")),
785            symbol_id: Some(SymbolId::new("s1")),
786        };
787        let edge = GraphEdge {
788            id: EdgeId::new("e1"),
789            from: node_a.id.clone(),
790            to: node_b.id.clone(),
791            edge_type: GraphEdgeType::Defines,
792            evidence: evidence(),
793        };
794
795        store
796            .replace_graph(
797                &[node_a.clone(), node_b.clone()],
798                std::slice::from_ref(&edge),
799            )
800            .unwrap();
801
802        let (nodes, edges) = store.neighbors("file:src/lib.rs", 10).unwrap();
803        assert_eq!(edges.len(), 1);
804        assert_eq!(edges[0].id.0, "e1");
805        assert!(nodes.iter().any(|n| n.id == node_a.id));
806    }
807
808    #[test]
809    fn shortest_path_finds_direct_route() {
810        let store = make_store();
811        let file = make_file("f1", "src/lib.rs");
812        let manifest = make_manifest();
813        let files = vec![file];
814        let data = IndexData {
815            manifest: &manifest,
816            files: &files,
817            symbols: &[],
818            occurrences: &[],
819            chunks: &[],
820            imports: &[],
821            tests: &[],
822        };
823        store.replace_index(data).unwrap();
824
825        let node_a = GraphNode {
826            id: NodeId::new("a"),
827            node_type: GraphNodeType::File,
828            label: "a".into(),
829            file_id: None,
830            symbol_id: None,
831        };
832        let node_b = GraphNode {
833            id: NodeId::new("b"),
834            node_type: GraphNodeType::File,
835            label: "b".into(),
836            file_id: None,
837            symbol_id: None,
838        };
839        let edge = GraphEdge {
840            id: EdgeId::new("a-b"),
841            from: node_a.id.clone(),
842            to: node_b.id.clone(),
843            edge_type: GraphEdgeType::Defines,
844            evidence: evidence(),
845        };
846        store.replace_graph(&[node_a, node_b], &[edge]).unwrap();
847
848        let path = store.shortest_path("a", "b", 5).unwrap();
849        assert_eq!(path.len(), 1);
850        assert_eq!(path[0].id.0, "a-b");
851    }
852
853    #[test]
854    fn shortest_path_returns_empty_when_no_route() {
855        let store = make_store();
856        let file = make_file("f1", "src/lib.rs");
857        let manifest = make_manifest();
858        let files = vec![file];
859        let data = IndexData {
860            manifest: &manifest,
861            files: &files,
862            symbols: &[],
863            occurrences: &[],
864            chunks: &[],
865            imports: &[],
866            tests: &[],
867        };
868        store.replace_index(data).unwrap();
869        store.replace_graph(&[], &[]).unwrap();
870
871        let path = store.shortest_path("x", "y", 5).unwrap();
872        assert!(path.is_empty());
873    }
874}