1use crate::error::{ForgeError, Result};
2use crate::knowledge::types::{QueryResult, SyncReport};
3use crate::knowledge::KnowledgeGraph;
4
5impl KnowledgeGraph {
6 pub fn resolve_fts5_by_magellan_id(&self, magellan_id: i64) -> Result<Option<i64>> {
7 let conn = rusqlite::Connection::open(&self.db_path)
8 .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?;
9
10 let result = conn
11 .query_row(
12 "SELECT node_id FROM graph_node_index WHERE magellan_id = ?1",
13 rusqlite::params![magellan_id],
14 |row| row.get::<_, i64>(0),
15 )
16 .ok();
17
18 Ok(result)
19 }
20
21 pub fn resolve_fts5(&self, keyword: &str) -> Result<Option<i64>> {
22 if !self.db_path.exists() {
23 return Ok(None);
24 }
25 let conn = rusqlite::Connection::open(&self.db_path)
26 .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?;
27 let pattern = format!("{}*", keyword);
28 let magellan_id: Option<i64> = conn
29 .query_row(
30 "SELECT rowid FROM symbol_fts WHERE symbol_fts MATCH ?1 LIMIT 1",
31 rusqlite::params![pattern],
32 |row| row.get(0),
33 )
34 .ok();
35 match magellan_id {
36 Some(mid) => self.resolve_fts5_by_magellan_id(mid),
37 None => Ok(None),
38 }
39 }
40
41 pub fn insert_bridge_entry(
42 &self,
43 node_id: i64,
44 magellan_id: i64,
45 graph_file: &str,
46 ) -> Result<()> {
47 let conn = rusqlite::Connection::open(&self.db_path)
48 .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?;
49
50 conn.execute_batch(
51 "CREATE TABLE IF NOT EXISTS graph_node_index (
52 node_id INTEGER PRIMARY KEY,
53 magellan_id INTEGER,
54 node_kind TEXT NOT NULL,
55 graph_file TEXT NOT NULL
56 );",
57 )
58 .map_err(|e| ForgeError::DatabaseError(format!("Create table failed: {}", e)))?;
59
60 conn.execute(
61 "INSERT OR REPLACE INTO graph_node_index (node_id, magellan_id, node_kind, graph_file)
62 VALUES (?1, ?2, 'symbol', ?3)",
63 rusqlite::params![node_id, magellan_id, graph_file],
64 )
65 .map_err(|e| ForgeError::DatabaseError(format!("Insert bridge failed: {}", e)))?;
66
67 Ok(())
68 }
69
70 pub async fn sync_symbols(&self) -> Result<SyncReport> {
71 if !self.db_path.exists() {
72 return Ok(SyncReport::default());
73 }
74 let conn = rusqlite::Connection::open(&self.db_path)
75 .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?;
76 let mut stmt =
77 match conn.prepare("SELECT id, kind, name, file_path FROM graph_entities LIMIT 5000") {
78 Ok(s) => s,
79 Err(_) => return Ok(SyncReport::default()),
80 };
81 let rows: Vec<(i64, String, String, Option<String>)> = stmt
82 .query_map([], |row| {
83 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
84 })
85 .map_err(|e| ForgeError::DatabaseError(format!("Query failed: {}", e)))?
86 .flatten()
87 .collect();
88 drop(stmt);
89 drop(conn);
90
91 let specs: Vec<sqlitegraph::backend::NodeSpec> = rows
92 .iter()
93 .map(|(_, kind, name, file_path)| {
94 let file = file_path.as_deref().unwrap_or("");
95 sqlitegraph::backend::NodeSpec {
96 kind: crate::knowledge::types::node::SYMBOL.to_string(),
97 name: name.clone(),
98 file_path: Some(file.to_string()),
99 data: serde_json::json!({
100 "symbol_kind": kind,
101 "qualified_name": name,
102 "file": file,
103 "line": 0u64,
104 "byte_start": 0u64,
105 "byte_end": 0u64,
106 "language": "unknown",
107 }),
108 }
109 })
110 .collect();
111
112 let kg_ids = self
113 .backend
114 .insert_nodes_bulk(&specs)
115 .map_err(|e| ForgeError::DatabaseError(format!("Bulk node insert failed: {}", e)))?;
116
117 let graph_file = self.graph_path.to_string_lossy().into_owned();
118 for ((magellan_id, ..), &kg_id) in rows.iter().zip(kg_ids.iter()) {
119 self.insert_bridge_entry(kg_id, *magellan_id, &graph_file)?;
120 }
121
122 Ok(SyncReport {
123 nodes_added: kg_ids.len(),
124 ..Default::default()
125 })
126 }
127
128 pub async fn sync_references(&self) -> Result<SyncReport> {
129 if !self.db_path.exists() {
130 return Ok(SyncReport::default());
131 }
132 let conn = rusqlite::Connection::open(&self.db_path)
133 .map_err(|e| ForgeError::DatabaseError(format!("Open db failed: {}", e)))?;
134 let mut stmt =
135 match conn.prepare("SELECT from_id, to_id, edge_type FROM graph_edges LIMIT 10000") {
136 Ok(s) => s,
137 Err(_) => return Ok(SyncReport::default()),
138 };
139 let edges: Vec<(i64, i64, String)> = stmt
140 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
141 .map_err(|e| ForgeError::DatabaseError(format!("Query failed: {}", e)))?
142 .flatten()
143 .collect();
144 drop(stmt);
145 drop(conn);
146
147 let mut specs = Vec::with_capacity(edges.len());
148 for (from_magellan, to_magellan, edge_type) in &edges {
149 if let (Some(from_id), Some(to_id)) = (
150 self.resolve_fts5_by_magellan_id(*from_magellan)?,
151 self.resolve_fts5_by_magellan_id(*to_magellan)?,
152 ) {
153 specs.push(sqlitegraph::backend::EdgeSpec {
154 from: from_id,
155 to: to_id,
156 edge_type: edge_type.clone(),
157 data: serde_json::Value::Null,
158 });
159 }
160 }
161
162 let edge_ids = self
163 .backend
164 .insert_edges_bulk(&specs)
165 .map_err(|e| ForgeError::DatabaseError(format!("Bulk edge insert failed: {}", e)))?;
166
167 Ok(SyncReport {
168 edges_added: edge_ids.len(),
169 ..Default::default()
170 })
171 }
172
173 pub async fn query(&self, keyword: &str, depth: u32) -> Result<QueryResult> {
174 let entry_id = self.resolve_fts5(keyword)?;
175 let Some(entry_id) = entry_id else {
176 return Ok(QueryResult::default());
177 };
178
179 let entry_node = self.get_node(entry_id).ok();
180 let callers = self.callers_of(entry_id, depth).unwrap_or_default();
181 let callees = self.callees_of(entry_id, depth).unwrap_or_default();
182 let correlated = self.correlated(entry_id).unwrap_or_default();
183 let affected = self.affected_by(entry_id, depth).unwrap_or_default();
184
185 Ok(QueryResult {
186 entry_node,
187 callers,
188 callees,
189 correlated,
190 affected,
191 similar: Vec::new(),
192 })
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use crate::knowledge::{KnowledgeGraph, SourceSpan};
199
200 fn setup_bridge_table(db_path: &std::path::Path) {
201 let conn = rusqlite::Connection::open(db_path).expect("invariant: temp db always opens");
202 conn.execute_batch(
203 "CREATE TABLE IF NOT EXISTS graph_node_index (
204 node_id INTEGER PRIMARY KEY,
205 magellan_id INTEGER,
206 node_kind TEXT NOT NULL,
207 graph_file TEXT NOT NULL
208 );",
209 )
210 .expect("invariant: DDL on fresh db succeeds");
211 }
212
213 fn setup_fts5_db(db_path: &std::path::Path, fn_name: &str) -> i64 {
214 use sqlitegraph::config::{open_graph, GraphConfig};
215 let config = GraphConfig::sqlite();
216 let backend = open_graph(db_path, &config).expect("invariant: fresh db always opens");
217 let node = sqlitegraph::backend::NodeSpec {
218 kind: "fn".to_string(),
219 name: fn_name.to_string(),
220 file_path: None,
221 data: serde_json::Value::Null,
222 };
223 let entity_id = backend
224 .insert_node(node)
225 .expect("invariant: fresh backend accepts inserts");
226 drop(backend);
227
228 let conn = rusqlite::Connection::open(db_path).expect("invariant: temp db always opens");
229 conn.execute_batch(
230 "CREATE VIRTUAL TABLE IF NOT EXISTS symbol_fts
231 USING fts5(name, content='graph_entities', content_rowid='id');
232 INSERT INTO symbol_fts(symbol_fts) VALUES('rebuild');",
233 )
234 .expect("invariant: DDL on fresh db succeeds");
235 entity_id
236 }
237
238 fn setup_entities_db(db_path: &std::path::Path, names: &[&str]) -> Vec<i64> {
239 use sqlitegraph::backend::NodeSpec;
240 use sqlitegraph::config::{open_graph, GraphConfig};
241 let config = GraphConfig::sqlite();
242 let backend = open_graph(db_path, &config).expect("invariant: fresh db always opens");
243 names
244 .iter()
245 .map(|name| {
246 backend
247 .insert_node(NodeSpec {
248 kind: "fn".to_string(),
249 name: name.to_string(),
250 file_path: Some("src/lib.rs".to_string()),
251 data: serde_json::Value::Null,
252 })
253 .expect("invariant: fresh backend accepts inserts")
254 })
255 .collect()
256 }
257
258 #[test]
259 fn test_fts5_resolve_empty() {
260 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
261 let db_path = temp.path().join("magellan.db");
262
263 setup_bridge_table(&db_path);
264
265 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
266 .expect("invariant: fresh temp paths always open");
267 let result = kg
268 .resolve_fts5("nonexistent")
269 .expect("invariant: fts5 lookup succeeds");
270 assert!(result.is_none());
271 }
272
273 #[test]
274 fn test_fts5_resolve_after_populate() {
275 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
276 let db_path = temp.path().join("magellan.db");
277
278 let conn = rusqlite::Connection::open(&db_path).expect("invariant: temp db always opens");
279 conn.execute_batch(
280 "CREATE TABLE IF NOT EXISTS graph_node_index (
281 node_id INTEGER PRIMARY KEY,
282 magellan_id INTEGER,
283 node_kind TEXT NOT NULL,
284 graph_file TEXT NOT NULL
285 );
286 INSERT INTO graph_node_index (node_id, magellan_id, node_kind, graph_file)
287 VALUES (47, 1, 'symbol', 'kg.graph');",
288 )
289 .expect("invariant: DDL on fresh db succeeds");
290
291 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
292 .expect("invariant: fresh temp paths always open");
293 let node_id = kg
294 .resolve_fts5_by_magellan_id(1)
295 .expect("invariant: bridge lookup succeeds");
296 assert_eq!(node_id, Some(47));
297 }
298
299 #[tokio::test]
300 async fn test_sync_symbols_empty_db() {
301 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
302 let db_path = temp.path().join("magellan.db");
303
304 setup_bridge_table(&db_path);
305
306 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
307 .expect("invariant: fresh temp paths always open");
308 let report = kg
309 .sync_symbols()
310 .await
311 .expect("invariant: sync on valid db succeeds");
312 assert_eq!(report.nodes_added, 0);
313 }
314
315 #[tokio::test]
316 async fn test_query_no_results() {
317 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
318 let db_path = temp.path().join("magellan.db");
319
320 setup_bridge_table(&db_path);
321
322 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
323 .expect("invariant: fresh temp paths always open");
324 let result = kg
325 .query("nonexistent", 3)
326 .await
327 .expect("invariant: query on valid graph succeeds");
328 assert!(result.entry_node.is_none());
329 assert!(result.callers.is_empty());
330 }
331
332 #[test]
333 fn test_query_traverse_from_bridge_entry() {
334 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
335 let db_path = temp.path().join("magellan.db");
336
337 setup_bridge_table(&db_path);
338
339 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
340 .expect("invariant: fresh temp paths always open");
341
342 let sym_id = kg
343 .add_symbol(
344 "my_func",
345 "Function",
346 "a::my_func",
347 &SourceSpan::new("f.rs", 1, 0, 10),
348 "Rust",
349 None,
350 )
351 .expect("invariant: fresh graph accepts inserts");
352 let caller_id = kg
353 .add_symbol(
354 "caller",
355 "Function",
356 "a::caller",
357 &SourceSpan::new("f.rs", 5, 0, 10),
358 "Rust",
359 None,
360 )
361 .expect("invariant: fresh graph accepts inserts");
362 kg.add_edge(caller_id, sym_id, "calls", serde_json::json!({}))
363 .expect("invariant: fresh graph accepts edge inserts");
364
365 kg.insert_bridge_entry(sym_id, 1, "kg.graph")
366 .expect("invariant: bridge insert on fresh db succeeds");
367
368 let entry = kg
369 .resolve_fts5_by_magellan_id(1)
370 .expect("invariant: bridge lookup succeeds");
371 assert_eq!(entry, Some(sym_id));
372
373 let callers = kg
374 .callers_of(sym_id, 1)
375 .expect("invariant: traversal on known graph succeeds");
376 assert_eq!(callers.len(), 1);
377 assert_eq!(callers[0].name, "caller");
378 }
379
380 #[test]
381 fn test_resolve_fts5_finds_indexed_symbol() {
382 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
383 let graph_path = temp.path().join("kg.graph");
384 let db_path = temp.path().join("magellan.db");
385
386 let magellan_id = setup_fts5_db(&db_path, "unique_resolve_target");
387 let kg = KnowledgeGraph::open(&graph_path, &db_path)
388 .expect("invariant: fresh temp paths always open");
389
390 let sym_id = kg
391 .add_symbol(
392 "unique_resolve_target",
393 "Function",
394 "crate::unique_resolve_target",
395 &SourceSpan::new("src/lib.rs", 1, 0, 10),
396 "Rust",
397 None,
398 )
399 .expect("invariant: fresh graph accepts inserts");
400 kg.insert_bridge_entry(sym_id, magellan_id, "kg.graph")
401 .expect("invariant: bridge insert on fresh db succeeds");
402
403 let result = kg
404 .resolve_fts5("unique_resolve_target")
405 .expect("invariant: fts5 lookup succeeds");
406 assert!(
407 result.is_some(),
408 "resolve_fts5 should find node via FTS5 index and bridge"
409 );
410 assert_eq!(result, Some(sym_id));
411 }
412
413 #[tokio::test]
414 async fn test_sync_symbols_inserts_entities() {
415 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
416 let db_path = temp.path().join("magellan.db");
417 setup_entities_db(&db_path, &["sync_fn_one", "sync_fn_two"]);
418 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
419 .expect("invariant: fresh temp paths always open");
420 let report = kg
421 .sync_symbols()
422 .await
423 .expect("invariant: sync on valid db succeeds");
424 assert_eq!(
425 report.nodes_added, 2,
426 "sync_symbols should add one KG node per magellan entity"
427 );
428 }
429
430 #[tokio::test]
431 async fn test_sync_symbols_bulk_all_bridge_entries_accessible() {
432 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
433 let db_path = temp.path().join("magellan.db");
434 let names = ["alpha", "beta", "gamma", "delta", "epsilon"];
435 let magellan_ids = setup_entities_db(&db_path, &names);
436
437 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
438 .expect("invariant: fresh temp paths always open");
439 let report = kg
440 .sync_symbols()
441 .await
442 .expect("invariant: sync on valid db succeeds");
443 assert_eq!(report.nodes_added, 5);
444
445 for mid in &magellan_ids {
446 let node_id = kg
447 .resolve_fts5_by_magellan_id(*mid)
448 .expect("invariant: bridge lookup succeeds");
449 assert!(
450 node_id.is_some(),
451 "bridge entry missing for magellan_id={mid}"
452 );
453 }
454 }
455
456 #[tokio::test]
457 async fn test_sync_references_inserts_edges() {
458 let temp = tempfile::tempdir().expect("invariant: tempdir creation succeeds");
459 let db_path = temp.path().join("magellan.db");
460 let ids = setup_entities_db(&db_path, &["caller_fn", "callee_fn"]);
461 {
462 let conn =
463 rusqlite::Connection::open(&db_path).expect("invariant: temp db always opens");
464 conn.execute(
465 "INSERT INTO graph_edges (from_id, to_id, edge_type, data) VALUES (?1, ?2, 'calls', '{}')",
466 rusqlite::params![ids[0], ids[1]],
467 )
468 .expect("invariant: DML on fresh db succeeds");
469 }
470 let kg = KnowledgeGraph::open(&temp.path().join("kg.graph"), &db_path)
471 .expect("invariant: fresh temp paths always open");
472 kg.sync_symbols()
473 .await
474 .expect("invariant: sync on valid db succeeds");
475 let ref_report = kg
476 .sync_references()
477 .await
478 .expect("invariant: sync on valid db succeeds");
479 assert_eq!(
480 ref_report.edges_added, 1,
481 "sync_references should add one KG edge per magellan graph_edge"
482 );
483 }
484}