lean_ctx/core/property_graph/
mod.rs1mod 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}