Skip to main content

lean_ctx/core/property_graph/
mod.rs

1//! Property Graph Engine — SQLite-backed code knowledge graph.
2//!
3//! Stores nodes (File, Symbol, Module) and edges (imports, calls, defines,
4//! exports) extracted by `deep_queries` + `import_resolver`.  Provides
5//! efficient traversal queries for impact analysis, architecture discovery,
6//! and graph-driven context loading.
7
8mod edge;
9mod meta;
10mod node;
11mod queries;
12mod schema;
13
14pub use edge::{Edge, EdgeKind};
15pub use meta::{load_meta, meta_path, write_meta, PropertyGraphMetaV1};
16pub use node::{Node, NodeKind};
17pub use queries::{
18    edge_weight, file_connectivity, related_files, DependencyChain, GraphQuery, ImpactResult,
19};
20
21use rusqlite::Connection;
22use std::path::{Path, PathBuf};
23
24pub struct CodeGraph {
25    conn: Connection,
26    db_path: PathBuf,
27}
28
29impl CodeGraph {
30    pub fn open(project_root: &Path) -> anyhow::Result<Self> {
31        let db_dir = project_root.join(".lean-ctx");
32        std::fs::create_dir_all(&db_dir)?;
33        let db_path = db_dir.join("graph.db");
34        let conn = Connection::open(&db_path)?;
35        conn.busy_timeout(std::time::Duration::from_secs(5))?;
36        schema::initialize(&conn)?;
37        Ok(Self { conn, db_path })
38    }
39
40    pub fn open_in_memory() -> anyhow::Result<Self> {
41        let conn = Connection::open_in_memory()?;
42        schema::initialize(&conn)?;
43        Ok(Self {
44            conn,
45            db_path: PathBuf::from(":memory:"),
46        })
47    }
48
49    pub fn db_path(&self) -> &Path {
50        &self.db_path
51    }
52
53    pub fn connection(&self) -> &Connection {
54        &self.conn
55    }
56
57    pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
58        node::upsert(&self.conn, node)
59    }
60
61    pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
62        edge::upsert(&self.conn, edge)
63    }
64
65    pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
66        node::get_by_path(&self.conn, file_path)
67    }
68
69    pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
70        node::get_by_symbol(&self.conn, name, file_path)
71    }
72
73    pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
74        node::remove_by_file(&self.conn, file_path)
75    }
76
77    pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
78        edge::from_node(&self.conn, node_id)
79    }
80
81    pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
82        edge::to_node(&self.conn, node_id)
83    }
84
85    pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
86        queries::dependents(&self.conn, file_path)
87    }
88
89    pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
90        queries::dependencies(&self.conn, file_path)
91    }
92
93    pub fn impact_analysis(
94        &self,
95        file_path: &str,
96        max_depth: usize,
97    ) -> anyhow::Result<ImpactResult> {
98        queries::impact_analysis(&self.conn, file_path, max_depth)
99    }
100
101    pub fn dependency_chain(
102        &self,
103        from: &str,
104        to: &str,
105    ) -> anyhow::Result<Option<DependencyChain>> {
106        queries::dependency_chain(&self.conn, from, to)
107    }
108
109    pub fn related_files(
110        &self,
111        file_path: &str,
112        limit: usize,
113    ) -> anyhow::Result<Vec<(String, f64)>> {
114        queries::related_files(&self.conn, file_path, limit)
115    }
116
117    pub fn file_connectivity(
118        &self,
119        file_path: &str,
120    ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
121        queries::file_connectivity(&self.conn, file_path)
122    }
123
124    pub fn node_count(&self) -> anyhow::Result<usize> {
125        node::count(&self.conn)
126    }
127
128    pub fn edge_count(&self) -> anyhow::Result<usize> {
129        edge::count(&self.conn)
130    }
131
132    pub fn clear(&self) -> anyhow::Result<()> {
133        self.conn
134            .execute_batch("DELETE FROM edges; DELETE FROM nodes;")?;
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn test_graph() -> CodeGraph {
144        CodeGraph::open_in_memory().unwrap()
145    }
146
147    #[test]
148    fn create_and_query_nodes() {
149        let g = test_graph();
150
151        let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
152        assert!(id > 0);
153
154        let found = g.get_node_by_path("src/main.rs").unwrap();
155        assert!(found.is_some());
156        assert_eq!(found.unwrap().file_path, "src/main.rs");
157    }
158
159    #[test]
160    fn create_and_query_edges() {
161        let g = test_graph();
162
163        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
164        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
165
166        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
167
168        let from_a = g.edges_from(a).unwrap();
169        assert_eq!(from_a.len(), 1);
170        assert_eq!(from_a[0].target_id, b);
171
172        let to_b = g.edges_to(b).unwrap();
173        assert_eq!(to_b.len(), 1);
174        assert_eq!(to_b[0].source_id, a);
175    }
176
177    #[test]
178    fn dependents_query() {
179        let g = test_graph();
180
181        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
182        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
183        let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
184
185        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
186            .unwrap();
187        g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
188            .unwrap();
189
190        let deps = g.dependents("src/lib.rs").unwrap();
191        assert_eq!(deps.len(), 2);
192        assert!(deps.contains(&"src/main.rs".to_string()));
193        assert!(deps.contains(&"src/utils.rs".to_string()));
194    }
195
196    #[test]
197    fn dependencies_query() {
198        let g = test_graph();
199
200        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
201        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
202        let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
203
204        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
205            .unwrap();
206        g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
207            .unwrap();
208
209        let deps = g.dependencies("src/main.rs").unwrap();
210        assert_eq!(deps.len(), 2);
211    }
212
213    #[test]
214    #[allow(clippy::many_single_char_names)] // graph test nodes: a, b, c, d, e
215    fn impact_analysis_depth() {
216        let g = test_graph();
217
218        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
219        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
220        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
221        let d = g.upsert_node(&Node::file("d.rs")).unwrap();
222
223        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
224        g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
225        g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
226
227        let impact = g.impact_analysis("a.rs", 2).unwrap();
228        assert!(impact.affected_files.contains(&"b.rs".to_string()));
229        assert!(impact.affected_files.contains(&"c.rs".to_string()));
230        assert!(!impact.affected_files.contains(&"d.rs".to_string()));
231
232        let deep = g.impact_analysis("a.rs", 10).unwrap();
233        assert!(deep.affected_files.contains(&"d.rs".to_string()));
234    }
235
236    #[test]
237    fn upsert_idempotent() {
238        let g = test_graph();
239
240        let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
241        let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
242        assert_eq!(id1, id2);
243        assert_eq!(g.node_count().unwrap(), 1);
244    }
245
246    #[test]
247    fn remove_file_cascades() {
248        let g = test_graph();
249
250        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
251        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
252        let sym = g
253            .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
254            .unwrap();
255
256        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
257        g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
258
259        g.remove_file_nodes("src/a.rs").unwrap();
260
261        assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
262        assert_eq!(g.edge_count().unwrap(), 0);
263    }
264
265    #[test]
266    fn dependency_chain_found() {
267        let g = test_graph();
268
269        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
270        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
271        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
272
273        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
274        g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
275
276        let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
277        assert!(chain.is_some());
278        let chain = chain.unwrap();
279        assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
280    }
281
282    #[test]
283    fn counts() {
284        let g = test_graph();
285        assert_eq!(g.node_count().unwrap(), 0);
286        assert_eq!(g.edge_count().unwrap(), 0);
287
288        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
289        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
290        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
291
292        assert_eq!(g.node_count().unwrap(), 2);
293        assert_eq!(g.edge_count().unwrap(), 1);
294    }
295
296    #[test]
297    fn multi_edge_dependents() {
298        let g = test_graph();
299
300        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
301        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
302        let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
303
304        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
305        g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
306
307        let deps = g.dependents("src/a.rs").unwrap();
308        assert_eq!(deps.len(), 2);
309        assert!(deps.contains(&"src/b.rs".to_string()));
310        assert!(deps.contains(&"src/c.rs".to_string()));
311    }
312
313    #[test]
314    fn multi_edge_impact_analysis() {
315        let g = test_graph();
316
317        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
318        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
319        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
320
321        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
322        g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
323
324        let impact = g.impact_analysis("a.rs", 10).unwrap();
325        assert!(impact.affected_files.contains(&"b.rs".to_string()));
326        assert!(impact.affected_files.contains(&"c.rs".to_string()));
327    }
328
329    #[test]
330    fn related_files_scored() {
331        let g = test_graph();
332
333        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
334        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
335        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
336
337        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
338        g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
339        g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
340
341        let related = g.related_files("a.rs", 10).unwrap();
342        assert_eq!(related.len(), 2);
343        let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
344        let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
345        assert!(
346            b_score > c_score,
347            "b.rs has imports+calls, should rank higher than c.rs with type_ref"
348        );
349    }
350}