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