Skip to main content

forgekit_core/knowledge/
sync.rs

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}