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 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)] 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}