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 node;
10mod queries;
11mod schema;
12
13pub use edge::{Edge, EdgeKind};
14pub use node::{Node, NodeKind};
15pub use queries::{DependencyChain, GraphQuery, ImpactResult};
16
17use rusqlite::Connection;
18use std::path::{Path, PathBuf};
19
20pub struct CodeGraph {
21    conn: Connection,
22    db_path: PathBuf,
23}
24
25impl CodeGraph {
26    pub fn open(project_root: &Path) -> anyhow::Result<Self> {
27        let db_dir = project_root.join(".lean-ctx");
28        std::fs::create_dir_all(&db_dir)?;
29        let db_path = db_dir.join("graph.db");
30        let conn = Connection::open(&db_path)?;
31        schema::initialize(&conn)?;
32        Ok(Self { conn, db_path })
33    }
34
35    pub fn open_in_memory() -> anyhow::Result<Self> {
36        let conn = Connection::open_in_memory()?;
37        schema::initialize(&conn)?;
38        Ok(Self {
39            conn,
40            db_path: PathBuf::from(":memory:"),
41        })
42    }
43
44    pub fn db_path(&self) -> &Path {
45        &self.db_path
46    }
47
48    pub fn connection(&self) -> &Connection {
49        &self.conn
50    }
51
52    pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
53        node::upsert(&self.conn, node)
54    }
55
56    pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
57        edge::upsert(&self.conn, edge)
58    }
59
60    pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
61        node::get_by_path(&self.conn, file_path)
62    }
63
64    pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
65        node::get_by_symbol(&self.conn, name, file_path)
66    }
67
68    pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
69        node::remove_by_file(&self.conn, file_path)
70    }
71
72    pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
73        edge::from_node(&self.conn, node_id)
74    }
75
76    pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
77        edge::to_node(&self.conn, node_id)
78    }
79
80    pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
81        queries::dependents(&self.conn, file_path)
82    }
83
84    pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
85        queries::dependencies(&self.conn, file_path)
86    }
87
88    pub fn impact_analysis(
89        &self,
90        file_path: &str,
91        max_depth: usize,
92    ) -> anyhow::Result<ImpactResult> {
93        queries::impact_analysis(&self.conn, file_path, max_depth)
94    }
95
96    pub fn dependency_chain(
97        &self,
98        from: &str,
99        to: &str,
100    ) -> anyhow::Result<Option<DependencyChain>> {
101        queries::dependency_chain(&self.conn, from, to)
102    }
103
104    pub fn node_count(&self) -> anyhow::Result<usize> {
105        node::count(&self.conn)
106    }
107
108    pub fn edge_count(&self) -> anyhow::Result<usize> {
109        edge::count(&self.conn)
110    }
111
112    pub fn clear(&self) -> anyhow::Result<()> {
113        self.conn
114            .execute_batch("DELETE FROM edges; DELETE FROM nodes;")?;
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn test_graph() -> CodeGraph {
124        CodeGraph::open_in_memory().unwrap()
125    }
126
127    #[test]
128    fn create_and_query_nodes() {
129        let g = test_graph();
130
131        let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
132        assert!(id > 0);
133
134        let found = g.get_node_by_path("src/main.rs").unwrap();
135        assert!(found.is_some());
136        assert_eq!(found.unwrap().file_path, "src/main.rs");
137    }
138
139    #[test]
140    fn create_and_query_edges() {
141        let g = test_graph();
142
143        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
144        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
145
146        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
147
148        let from_a = g.edges_from(a).unwrap();
149        assert_eq!(from_a.len(), 1);
150        assert_eq!(from_a[0].target_id, b);
151
152        let to_b = g.edges_to(b).unwrap();
153        assert_eq!(to_b.len(), 1);
154        assert_eq!(to_b[0].source_id, a);
155    }
156
157    #[test]
158    fn dependents_query() {
159        let g = test_graph();
160
161        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
162        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
163        let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
164
165        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
166            .unwrap();
167        g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
168            .unwrap();
169
170        let deps = g.dependents("src/lib.rs").unwrap();
171        assert_eq!(deps.len(), 2);
172        assert!(deps.contains(&"src/main.rs".to_string()));
173        assert!(deps.contains(&"src/utils.rs".to_string()));
174    }
175
176    #[test]
177    fn dependencies_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 config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
183
184        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
185            .unwrap();
186        g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
187            .unwrap();
188
189        let deps = g.dependencies("src/main.rs").unwrap();
190        assert_eq!(deps.len(), 2);
191    }
192
193    #[test]
194    fn impact_analysis_depth() {
195        let g = test_graph();
196
197        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
198        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
199        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
200        let d = g.upsert_node(&Node::file("d.rs")).unwrap();
201
202        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
203        g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
204        g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
205
206        let impact = g.impact_analysis("a.rs", 2).unwrap();
207        assert!(impact.affected_files.contains(&"b.rs".to_string()));
208        assert!(impact.affected_files.contains(&"c.rs".to_string()));
209        assert!(!impact.affected_files.contains(&"d.rs".to_string()));
210
211        let deep = g.impact_analysis("a.rs", 10).unwrap();
212        assert!(deep.affected_files.contains(&"d.rs".to_string()));
213    }
214
215    #[test]
216    fn upsert_idempotent() {
217        let g = test_graph();
218
219        let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
220        let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
221        assert_eq!(id1, id2);
222        assert_eq!(g.node_count().unwrap(), 1);
223    }
224
225    #[test]
226    fn remove_file_cascades() {
227        let g = test_graph();
228
229        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
230        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
231        let sym = g
232            .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
233            .unwrap();
234
235        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
236        g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
237
238        g.remove_file_nodes("src/a.rs").unwrap();
239
240        assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
241        assert_eq!(g.edge_count().unwrap(), 0);
242    }
243
244    #[test]
245    fn dependency_chain_found() {
246        let g = test_graph();
247
248        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
249        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
250        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
251
252        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
253        g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
254
255        let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
256        assert!(chain.is_some());
257        let chain = chain.unwrap();
258        assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
259    }
260
261    #[test]
262    fn counts() {
263        let g = test_graph();
264        assert_eq!(g.node_count().unwrap(), 0);
265        assert_eq!(g.edge_count().unwrap(), 0);
266
267        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
268        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
269        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
270
271        assert_eq!(g.node_count().unwrap(), 2);
272        assert_eq!(g.edge_count().unwrap(), 1);
273    }
274}