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