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 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 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}