lean_ctx/core/property_graph/
mod.rs1mod edge;
9pub mod file_catalog;
10mod meta;
11mod node;
12mod queries;
13mod schema;
14pub mod snapshot;
15
16pub use edge::{Edge, EdgeKind};
17pub use file_catalog::FileCatalogEntry;
18pub use meta::{load_meta, meta_path, write_meta, PropertyGraphMetaV1};
19pub use node::{Node, NodeKind};
20pub use queries::{
21 edge_weight, file_connectivity, related_files, DependencyChain, GraphQuery, ImpactResult,
22};
23
24use rusqlite::Connection;
25use std::path::{Path, PathBuf};
26
27pub fn graph_dir(project_root: &str) -> PathBuf {
33 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
34 let normalized = crate::core::graph_index::normalize_project_root(project_root);
35 let hash = crate::core::project_hash::hash_project_root(&normalized);
36 data_dir.join("graphs").join(hash)
37 } else {
38 Path::new(project_root).join(".lean-ctx")
39 }
40}
41
42fn migrate_if_needed(project_root: &str, new_dir: &Path) {
45 let old_dir = Path::new(project_root).join(".lean-ctx");
46 if old_dir == new_dir {
47 return;
48 }
49 for file in &["graph.db", "graph.meta.json"] {
50 let old = old_dir.join(file);
51 let new = new_dir.join(file);
52 if old.exists()
53 && !new.exists()
54 && std::fs::rename(&old, &new).is_err()
55 && std::fs::copy(&old, &new).is_ok()
56 {
57 let _ = std::fs::remove_file(&old);
58 }
59 }
60}
61
62pub struct CodeGraph {
63 conn: Connection,
64 db_path: PathBuf,
65}
66
67impl CodeGraph {
68 pub fn open(project_root: &str) -> anyhow::Result<Self> {
69 let db_dir = graph_dir(project_root);
70 std::fs::create_dir_all(&db_dir)?;
71 migrate_if_needed(project_root, &db_dir);
72 let db_path = db_dir.join("graph.db");
73 let conn = Connection::open(&db_path)?;
74 conn.busy_timeout(std::time::Duration::from_secs(5))?;
75 schema::initialize(&conn)?;
76 Ok(Self { conn, db_path })
77 }
78
79 pub fn open_in_memory() -> anyhow::Result<Self> {
80 let conn = Connection::open_in_memory()?;
81 schema::initialize(&conn)?;
82 Ok(Self {
83 conn,
84 db_path: PathBuf::from(":memory:"),
85 })
86 }
87
88 pub fn db_path(&self) -> &Path {
89 &self.db_path
90 }
91
92 pub fn connection(&self) -> &Connection {
93 &self.conn
94 }
95
96 pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
97 node::upsert(&self.conn, node)
98 }
99
100 pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
101 edge::upsert(&self.conn, edge)
102 }
103
104 pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
105 node::get_by_path(&self.conn, file_path)
106 }
107
108 pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
109 node::get_by_symbol(&self.conn, name, file_path)
110 }
111
112 pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
113 node::remove_by_file(&self.conn, file_path)
114 }
115
116 pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
117 edge::from_node(&self.conn, node_id)
118 }
119
120 pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
121 edge::to_node(&self.conn, node_id)
122 }
123
124 pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
125 queries::dependents(&self.conn, file_path)
126 }
127
128 pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
129 queries::dependencies(&self.conn, file_path)
130 }
131
132 pub fn impact_analysis(
133 &self,
134 file_path: &str,
135 max_depth: usize,
136 ) -> anyhow::Result<ImpactResult> {
137 queries::impact_analysis(&self.conn, file_path, max_depth)
138 }
139
140 pub fn dependency_chain(
141 &self,
142 from: &str,
143 to: &str,
144 ) -> anyhow::Result<Option<DependencyChain>> {
145 queries::dependency_chain(&self.conn, from, to)
146 }
147
148 pub fn related_files(
149 &self,
150 file_path: &str,
151 limit: usize,
152 ) -> anyhow::Result<Vec<(String, f64)>> {
153 queries::related_files(&self.conn, file_path, limit)
154 }
155
156 pub fn file_connectivity(
157 &self,
158 file_path: &str,
159 ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
160 queries::file_connectivity(&self.conn, file_path)
161 }
162
163 pub fn node_count(&self) -> anyhow::Result<usize> {
164 node::count(&self.conn)
165 }
166
167 pub fn edge_count(&self) -> anyhow::Result<usize> {
168 edge::count(&self.conn)
169 }
170
171 pub fn clear(&self) -> anyhow::Result<()> {
172 self.conn
173 .execute_batch("DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog;")?;
174 Ok(())
175 }
176
177 pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
178 file_catalog::upsert(&self.conn, entry)
179 }
180
181 pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
182 file_catalog::get(&self.conn, path)
183 }
184
185 pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
186 file_catalog::count(&self.conn)
187 }
188
189 pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
190 file_catalog::all_paths(&self.conn)
191 }
192
193 pub fn find_symbols(
194 &self,
195 name: &str,
196 file_filter: Option<&str>,
197 kind_filter: Option<&str>,
198 ) -> anyhow::Result<Vec<Node>> {
199 node::find_symbols(&self.conn, name, file_filter, kind_filter)
200 }
201
202 pub fn symbol_count(&self) -> anyhow::Result<usize> {
203 node::symbol_count(&self.conn)
204 }
205
206 pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
207 node::all_edges_flat(&self.conn)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::core::data_dir::test_env_lock;
215
216 fn test_graph() -> CodeGraph {
217 CodeGraph::open_in_memory().unwrap()
218 }
219
220 #[test]
221 fn create_and_query_nodes() {
222 let g = test_graph();
223
224 let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
225 assert!(id > 0);
226
227 let found = g.get_node_by_path("src/main.rs").unwrap();
228 assert!(found.is_some());
229 assert_eq!(found.unwrap().file_path, "src/main.rs");
230 }
231
232 #[test]
233 fn create_and_query_edges() {
234 let g = test_graph();
235
236 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
237 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
238
239 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
240
241 let from_a = g.edges_from(a).unwrap();
242 assert_eq!(from_a.len(), 1);
243 assert_eq!(from_a[0].target_id, b);
244
245 let to_b = g.edges_to(b).unwrap();
246 assert_eq!(to_b.len(), 1);
247 assert_eq!(to_b[0].source_id, a);
248 }
249
250 #[test]
251 fn dependents_query() {
252 let g = test_graph();
253
254 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
255 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
256 let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
257
258 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
259 .unwrap();
260 g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
261 .unwrap();
262
263 let deps = g.dependents("src/lib.rs").unwrap();
264 assert_eq!(deps.len(), 2);
265 assert!(deps.contains(&"src/main.rs".to_string()));
266 assert!(deps.contains(&"src/utils.rs".to_string()));
267 }
268
269 #[test]
270 fn dependencies_query() {
271 let g = test_graph();
272
273 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
274 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
275 let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
276
277 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
278 .unwrap();
279 g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
280 .unwrap();
281
282 let deps = g.dependencies("src/main.rs").unwrap();
283 assert_eq!(deps.len(), 2);
284 }
285
286 #[test]
287 #[allow(clippy::many_single_char_names)] fn impact_analysis_depth() {
289 let g = test_graph();
290
291 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
292 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
293 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
294 let d = g.upsert_node(&Node::file("d.rs")).unwrap();
295
296 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
297 g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
298 g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
299
300 let impact = g.impact_analysis("a.rs", 2).unwrap();
301 assert!(impact.affected_files.contains(&"b.rs".to_string()));
302 assert!(impact.affected_files.contains(&"c.rs".to_string()));
303 assert!(!impact.affected_files.contains(&"d.rs".to_string()));
304
305 let deep = g.impact_analysis("a.rs", 10).unwrap();
306 assert!(deep.affected_files.contains(&"d.rs".to_string()));
307 }
308
309 #[test]
310 fn upsert_idempotent() {
311 let g = test_graph();
312
313 let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
314 let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
315 assert_eq!(id1, id2);
316 assert_eq!(g.node_count().unwrap(), 1);
317 }
318
319 #[test]
320 fn remove_file_cascades() {
321 let g = test_graph();
322
323 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
324 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
325 let sym = g
326 .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
327 .unwrap();
328
329 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
330 g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
331
332 g.remove_file_nodes("src/a.rs").unwrap();
333
334 assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
335 assert_eq!(g.edge_count().unwrap(), 0);
336 }
337
338 #[test]
339 fn dependency_chain_found() {
340 let g = test_graph();
341
342 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
343 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
344 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
345
346 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
347 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
348
349 let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
350 assert!(chain.is_some());
351 let chain = chain.unwrap();
352 assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
353 }
354
355 #[test]
356 fn counts() {
357 let g = test_graph();
358 assert_eq!(g.node_count().unwrap(), 0);
359 assert_eq!(g.edge_count().unwrap(), 0);
360
361 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
362 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
363 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
364
365 assert_eq!(g.node_count().unwrap(), 2);
366 assert_eq!(g.edge_count().unwrap(), 1);
367 }
368
369 #[test]
370 fn multi_edge_dependents() {
371 let g = test_graph();
372
373 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
374 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
375 let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
376
377 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
378 g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
379
380 let deps = g.dependents("src/a.rs").unwrap();
381 assert_eq!(deps.len(), 2);
382 assert!(deps.contains(&"src/b.rs".to_string()));
383 assert!(deps.contains(&"src/c.rs".to_string()));
384 }
385
386 #[test]
387 fn multi_edge_impact_analysis() {
388 let g = test_graph();
389
390 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
391 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
392 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
393
394 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
395 g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
396
397 let impact = g.impact_analysis("a.rs", 10).unwrap();
398 assert!(impact.affected_files.contains(&"b.rs".to_string()));
399 assert!(impact.affected_files.contains(&"c.rs".to_string()));
400 }
401
402 #[test]
403 fn related_files_scored() {
404 let g = test_graph();
405
406 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
407 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
408 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
409
410 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
411 g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
412 g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
413
414 let related = g.related_files("a.rs", 10).unwrap();
415 assert_eq!(related.len(), 2);
416 let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
417 let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
418 assert!(
419 b_score > c_score,
420 "b.rs has imports+calls, should rank higher than c.rs with type_ref"
421 );
422 }
423
424 #[test]
425 fn graph_dir_uses_data_dir_when_set() {
426 let tmp = tempfile::tempdir().unwrap();
427 let project = tmp.path().join("myproject");
428 std::fs::create_dir_all(&project).unwrap();
429
430 let data_dir = tmp.path().join("data");
431 std::fs::create_dir_all(&data_dir).unwrap();
432
433 let _guard = test_env_lock();
434 std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
435
436 let dir = graph_dir(project.to_str().unwrap());
437 assert!(dir.starts_with(&data_dir));
438 assert!(dir.to_string_lossy().contains("graphs"));
439
440 std::env::remove_var("LEAN_CTX_DATA_DIR");
441 }
442
443 #[test]
444 fn graph_dir_returns_consistent_hash_dir() {
445 let tmp = tempfile::tempdir().unwrap();
446 let project = tmp.path().join("hash_project");
447 std::fs::create_dir_all(&project).unwrap();
448
449 let data_dir = tmp.path().join("data2");
450 std::fs::create_dir_all(&data_dir).unwrap();
451
452 let _guard = test_env_lock();
453 std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
454
455 let dir1 = graph_dir(project.to_str().unwrap());
456 let dir2 = graph_dir(project.to_str().unwrap());
457 assert_eq!(dir1, dir2, "graph_dir should be deterministic");
458 assert!(dir1.to_string_lossy().contains("graphs"));
459
460 std::env::remove_var("LEAN_CTX_DATA_DIR");
461 }
462
463 #[test]
464 fn migration_moves_old_files() {
465 let tmp = tempfile::tempdir().unwrap();
466 let project = tmp.path().join("migtest");
467 let old_dir = project.join(".lean-ctx");
468 std::fs::create_dir_all(&old_dir).unwrap();
469 std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
470 std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
471
472 let new_dir = tmp.path().join("newloc");
473 std::fs::create_dir_all(&new_dir).unwrap();
474
475 migrate_if_needed(project.to_str().unwrap(), &new_dir);
476
477 assert!(new_dir.join("graph.db").exists());
478 assert!(new_dir.join("graph.meta.json").exists());
479 assert!(!old_dir.join("graph.db").exists());
480 assert!(!old_dir.join("graph.meta.json").exists());
481 assert_eq!(
482 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
483 "old-db-content"
484 );
485 }
486
487 #[test]
488 fn migration_skips_when_new_exists() {
489 let tmp = tempfile::tempdir().unwrap();
490 let project = tmp.path().join("skiptest");
491 let old_dir = project.join(".lean-ctx");
492 std::fs::create_dir_all(&old_dir).unwrap();
493 std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
494
495 let new_dir = tmp.path().join("newloc2");
496 std::fs::create_dir_all(&new_dir).unwrap();
497 std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
498
499 migrate_if_needed(project.to_str().unwrap(), &new_dir);
500
501 assert_eq!(
502 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
503 "already-there"
504 );
505 assert!(old_dir.join("graph.db").exists());
506 }
507
508 #[test]
509 fn open_with_data_dir() {
510 let tmp = tempfile::tempdir().unwrap();
511 let project = tmp.path().join("opentest");
512 std::fs::create_dir_all(&project).unwrap();
513
514 let data_dir = tmp.path().join("xdata");
515 std::fs::create_dir_all(&data_dir).unwrap();
516
517 let _guard = test_env_lock();
518 std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
519
520 let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
521 assert!(g.db_path().starts_with(&data_dir));
522 assert!(g.db_path().to_string_lossy().contains("graph.db"));
523
524 std::env::remove_var("LEAN_CTX_DATA_DIR");
525 }
526
527 #[test]
528 fn meta_path_uses_graph_dir() {
529 let tmp = tempfile::tempdir().unwrap();
530 let project = tmp.path().join("metatest");
531 std::fs::create_dir_all(&project).unwrap();
532
533 let data_dir = tmp.path().join("mdata");
534 std::fs::create_dir_all(&data_dir).unwrap();
535
536 let _guard = test_env_lock();
537 std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
538
539 let mp = meta::meta_path(project.to_str().unwrap());
540 assert!(mp.starts_with(&data_dir));
541 assert!(mp.to_string_lossy().contains("graph.meta.json"));
542
543 std::env::remove_var("LEAN_CTX_DATA_DIR");
544 }
545}