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;
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
27/// Resolve the directory for graph.db and graph.meta.json.
28///
29/// Uses `$LEAN_CTX_DATA_DIR/graphs/<project_hash>/` (consistent with
30/// `ProjectIndex::index_dir`).  Falls back to `<project>/.lean-ctx/`
31/// only when the global data directory cannot be resolved.
32pub 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
42/// Transparently migrate graph.db and graph.meta.json from the old
43/// per-project `.lean-ctx/` directory to the new `$DATA_DIR/graphs/` path.
44fn 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)] // graph test nodes: a, b, c, d, e
288    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}