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::{PropertyGraphMetaV1, load_meta, meta_path, write_meta};
19pub use node::{Node, NodeKind};
20pub use queries::{
21    DependencyChain, GraphQuery, ImpactResult, edge_weight, file_connectivity, related_files,
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
62/// Property-graph engine generation. Bump whenever edge extraction changes
63/// (e.g. the `type_ref` edges that connect C#/Java same-namespace consumers to
64/// their definers, GH #398) so an existing graph built by an older engine is
65/// transparently rebuilt on the next query instead of being served without the
66/// new edges. Graphs whose `graph.meta.json` predates this stamp deserialize to
67/// engine version `0`, so the first query after an upgrade rebuilds once.
68pub const GRAPH_ENGINE_VERSION: u32 = 2;
69
70/// `true` when the persisted graph was built by an engine older than
71/// [`GRAPH_ENGINE_VERSION`] — or predates the version stamp entirely (missing or
72/// unreadable meta) — and must therefore be rebuilt before its edges can be
73/// trusted. Callers pair this with a node-count check: an empty graph is rebuilt
74/// regardless; a non-empty-but-outdated graph is rebuilt by this gate.
75pub fn engine_outdated(project_root: &str) -> bool {
76    load_meta(project_root).is_none_or(|m| m.engine_version < GRAPH_ENGINE_VERSION)
77}
78
79pub struct CodeGraph {
80    conn: Connection,
81    db_path: PathBuf,
82}
83
84impl CodeGraph {
85    pub fn open(project_root: &str) -> anyhow::Result<Self> {
86        let db_dir = graph_dir(project_root);
87        std::fs::create_dir_all(&db_dir)?;
88        migrate_if_needed(project_root, &db_dir);
89        let db_path = db_dir.join("graph.db");
90        let conn = Connection::open(&db_path)?;
91        conn.busy_timeout(std::time::Duration::from_secs(5))?;
92        schema::initialize(&conn)?;
93        Ok(Self { conn, db_path })
94    }
95
96    pub fn open_in_memory() -> anyhow::Result<Self> {
97        let conn = Connection::open_in_memory()?;
98        schema::initialize(&conn)?;
99        Ok(Self {
100            conn,
101            db_path: PathBuf::from(":memory:"),
102        })
103    }
104
105    pub fn db_path(&self) -> &Path {
106        &self.db_path
107    }
108
109    pub fn connection(&self) -> &Connection {
110        &self.conn
111    }
112
113    pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
114        node::upsert(&self.conn, node)
115    }
116
117    pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
118        edge::upsert(&self.conn, edge)
119    }
120
121    pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
122        node::get_by_path(&self.conn, file_path)
123    }
124
125    pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
126        node::get_by_symbol(&self.conn, name, file_path)
127    }
128
129    pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
130        node::remove_by_file(&self.conn, file_path)
131    }
132
133    pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
134        edge::from_node(&self.conn, node_id)
135    }
136
137    pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
138        edge::to_node(&self.conn, node_id)
139    }
140
141    pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
142        queries::dependents(&self.conn, file_path)
143    }
144
145    pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
146        queries::dependencies(&self.conn, file_path)
147    }
148
149    pub fn impact_analysis(
150        &self,
151        file_path: &str,
152        max_depth: usize,
153    ) -> anyhow::Result<ImpactResult> {
154        queries::impact_analysis(&self.conn, file_path, max_depth)
155    }
156
157    pub fn dependency_chain(
158        &self,
159        from: &str,
160        to: &str,
161    ) -> anyhow::Result<Option<DependencyChain>> {
162        queries::dependency_chain(&self.conn, from, to)
163    }
164
165    pub fn related_files(
166        &self,
167        file_path: &str,
168        limit: usize,
169    ) -> anyhow::Result<Vec<(String, f64)>> {
170        queries::related_files(&self.conn, file_path, limit)
171    }
172
173    pub fn file_connectivity(
174        &self,
175        file_path: &str,
176    ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
177        queries::file_connectivity(&self.conn, file_path)
178    }
179
180    pub fn node_count(&self) -> anyhow::Result<usize> {
181        node::count(&self.conn)
182    }
183
184    pub fn edge_count(&self) -> anyhow::Result<usize> {
185        edge::count(&self.conn)
186    }
187
188    pub fn clear(&self) -> anyhow::Result<()> {
189        self.conn
190            .execute_batch("DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog;")?;
191        Ok(())
192    }
193
194    pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
195        file_catalog::upsert(&self.conn, entry)
196    }
197
198    pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
199        file_catalog::get(&self.conn, path)
200    }
201
202    pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
203        file_catalog::count(&self.conn)
204    }
205
206    pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
207        file_catalog::all_paths(&self.conn)
208    }
209
210    pub fn find_symbols(
211        &self,
212        name: &str,
213        file_filter: Option<&str>,
214        kind_filter: Option<&str>,
215    ) -> anyhow::Result<Vec<Node>> {
216        node::find_symbols(&self.conn, name, file_filter, kind_filter)
217    }
218
219    pub fn symbol_count(&self) -> anyhow::Result<usize> {
220        node::symbol_count(&self.conn)
221    }
222
223    pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
224        node::all_edges_flat(&self.conn)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::core::data_dir::test_env_lock;
232
233    fn test_graph() -> CodeGraph {
234        CodeGraph::open_in_memory().unwrap()
235    }
236
237    #[test]
238    fn create_and_query_nodes() {
239        let g = test_graph();
240
241        let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
242        assert!(id > 0);
243
244        let found = g.get_node_by_path("src/main.rs").unwrap();
245        assert!(found.is_some());
246        assert_eq!(found.unwrap().file_path, "src/main.rs");
247    }
248
249    #[test]
250    fn create_and_query_edges() {
251        let g = test_graph();
252
253        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
254        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
255
256        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
257
258        let from_a = g.edges_from(a).unwrap();
259        assert_eq!(from_a.len(), 1);
260        assert_eq!(from_a[0].target_id, b);
261
262        let to_b = g.edges_to(b).unwrap();
263        assert_eq!(to_b.len(), 1);
264        assert_eq!(to_b[0].source_id, a);
265    }
266
267    #[test]
268    fn dependents_query() {
269        let g = test_graph();
270
271        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
272        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
273        let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
274
275        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
276            .unwrap();
277        g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
278            .unwrap();
279
280        let deps = g.dependents("src/lib.rs").unwrap();
281        assert_eq!(deps.len(), 2);
282        assert!(deps.contains(&"src/main.rs".to_string()));
283        assert!(deps.contains(&"src/utils.rs".to_string()));
284    }
285
286    #[test]
287    fn dependencies_query() {
288        let g = test_graph();
289
290        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
291        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
292        let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
293
294        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
295            .unwrap();
296        g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
297            .unwrap();
298
299        let deps = g.dependencies("src/main.rs").unwrap();
300        assert_eq!(deps.len(), 2);
301    }
302
303    #[test]
304    #[allow(clippy::many_single_char_names)] // graph test nodes: a, b, c, d, e
305    fn impact_analysis_depth() {
306        let g = test_graph();
307
308        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
309        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
310        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
311        let d = g.upsert_node(&Node::file("d.rs")).unwrap();
312
313        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
314        g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
315        g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
316
317        let impact = g.impact_analysis("a.rs", 2).unwrap();
318        assert!(impact.affected_files.contains(&"b.rs".to_string()));
319        assert!(impact.affected_files.contains(&"c.rs".to_string()));
320        assert!(!impact.affected_files.contains(&"d.rs".to_string()));
321
322        let deep = g.impact_analysis("a.rs", 10).unwrap();
323        assert!(deep.affected_files.contains(&"d.rs".to_string()));
324    }
325
326    #[test]
327    fn upsert_idempotent() {
328        let g = test_graph();
329
330        let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
331        let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
332        assert_eq!(id1, id2);
333        assert_eq!(g.node_count().unwrap(), 1);
334    }
335
336    #[test]
337    fn remove_file_cascades() {
338        let g = test_graph();
339
340        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
341        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
342        let sym = g
343            .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
344            .unwrap();
345
346        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
347        g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
348
349        g.remove_file_nodes("src/a.rs").unwrap();
350
351        assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
352        assert_eq!(g.edge_count().unwrap(), 0);
353    }
354
355    #[test]
356    fn dependency_chain_found() {
357        let g = test_graph();
358
359        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
360        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
361        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
362
363        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
364        g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
365
366        let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
367        assert!(chain.is_some());
368        let chain = chain.unwrap();
369        assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
370    }
371
372    #[test]
373    fn counts() {
374        let g = test_graph();
375        assert_eq!(g.node_count().unwrap(), 0);
376        assert_eq!(g.edge_count().unwrap(), 0);
377
378        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
379        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
380        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
381
382        assert_eq!(g.node_count().unwrap(), 2);
383        assert_eq!(g.edge_count().unwrap(), 1);
384    }
385
386    #[test]
387    fn multi_edge_dependents() {
388        let g = test_graph();
389
390        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
391        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
392        let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
393
394        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
395        g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
396
397        let deps = g.dependents("src/a.rs").unwrap();
398        assert_eq!(deps.len(), 2);
399        assert!(deps.contains(&"src/b.rs".to_string()));
400        assert!(deps.contains(&"src/c.rs".to_string()));
401    }
402
403    #[test]
404    fn multi_edge_impact_analysis() {
405        let g = test_graph();
406
407        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
408        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
409        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
410
411        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
412        g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
413
414        let impact = g.impact_analysis("a.rs", 10).unwrap();
415        assert!(impact.affected_files.contains(&"b.rs".to_string()));
416        assert!(impact.affected_files.contains(&"c.rs".to_string()));
417    }
418
419    #[test]
420    fn related_files_scored() {
421        let g = test_graph();
422
423        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
424        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
425        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
426
427        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
428        g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
429        g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
430
431        let related = g.related_files("a.rs", 10).unwrap();
432        assert_eq!(related.len(), 2);
433        let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
434        let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
435        assert!(
436            b_score > c_score,
437            "b.rs has imports+calls, should rank higher than c.rs with type_ref"
438        );
439    }
440
441    #[test]
442    fn graph_dir_uses_data_dir_when_set() {
443        let tmp = tempfile::tempdir().unwrap();
444        let project = tmp.path().join("myproject");
445        std::fs::create_dir_all(&project).unwrap();
446
447        let data_dir = tmp.path().join("data");
448        std::fs::create_dir_all(&data_dir).unwrap();
449
450        let _guard = test_env_lock();
451        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
452
453        let dir = graph_dir(project.to_str().unwrap());
454        assert!(dir.starts_with(&data_dir));
455        assert!(dir.to_string_lossy().contains("graphs"));
456
457        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
458    }
459
460    #[test]
461    fn graph_dir_returns_consistent_hash_dir() {
462        let tmp = tempfile::tempdir().unwrap();
463        let project = tmp.path().join("hash_project");
464        std::fs::create_dir_all(&project).unwrap();
465
466        let data_dir = tmp.path().join("data2");
467        std::fs::create_dir_all(&data_dir).unwrap();
468
469        let _guard = test_env_lock();
470        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
471
472        let dir1 = graph_dir(project.to_str().unwrap());
473        let dir2 = graph_dir(project.to_str().unwrap());
474        assert_eq!(dir1, dir2, "graph_dir should be deterministic");
475        assert!(dir1.to_string_lossy().contains("graphs"));
476
477        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
478    }
479
480    #[test]
481    fn migration_moves_old_files() {
482        let tmp = tempfile::tempdir().unwrap();
483        let project = tmp.path().join("migtest");
484        let old_dir = project.join(".lean-ctx");
485        std::fs::create_dir_all(&old_dir).unwrap();
486        std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
487        std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
488
489        let new_dir = tmp.path().join("newloc");
490        std::fs::create_dir_all(&new_dir).unwrap();
491
492        migrate_if_needed(project.to_str().unwrap(), &new_dir);
493
494        assert!(new_dir.join("graph.db").exists());
495        assert!(new_dir.join("graph.meta.json").exists());
496        assert!(!old_dir.join("graph.db").exists());
497        assert!(!old_dir.join("graph.meta.json").exists());
498        assert_eq!(
499            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
500            "old-db-content"
501        );
502    }
503
504    #[test]
505    fn migration_skips_when_new_exists() {
506        let tmp = tempfile::tempdir().unwrap();
507        let project = tmp.path().join("skiptest");
508        let old_dir = project.join(".lean-ctx");
509        std::fs::create_dir_all(&old_dir).unwrap();
510        std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
511
512        let new_dir = tmp.path().join("newloc2");
513        std::fs::create_dir_all(&new_dir).unwrap();
514        std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
515
516        migrate_if_needed(project.to_str().unwrap(), &new_dir);
517
518        assert_eq!(
519            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
520            "already-there"
521        );
522        assert!(old_dir.join("graph.db").exists());
523    }
524
525    #[test]
526    fn open_with_data_dir() {
527        let tmp = tempfile::tempdir().unwrap();
528        let project = tmp.path().join("opentest");
529        std::fs::create_dir_all(&project).unwrap();
530
531        let data_dir = tmp.path().join("xdata");
532        std::fs::create_dir_all(&data_dir).unwrap();
533
534        let _guard = test_env_lock();
535        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
536
537        let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
538        assert!(g.db_path().starts_with(&data_dir));
539        assert!(g.db_path().to_string_lossy().contains("graph.db"));
540
541        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
542    }
543
544    #[test]
545    fn meta_path_uses_graph_dir() {
546        let tmp = tempfile::tempdir().unwrap();
547        let project = tmp.path().join("metatest");
548        std::fs::create_dir_all(&project).unwrap();
549
550        let data_dir = tmp.path().join("mdata");
551        std::fs::create_dir_all(&data_dir).unwrap();
552
553        let _guard = test_env_lock();
554        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
555
556        let mp = meta::meta_path(project.to_str().unwrap());
557        assert!(mp.starts_with(&data_dir));
558        assert!(mp.to_string_lossy().contains("graph.meta.json"));
559
560        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
561    }
562
563    #[test]
564    fn engine_outdated_flags_old_and_missing_meta() {
565        let _iso = crate::core::data_dir::isolated_data_dir();
566        let proj = tempfile::tempdir().unwrap();
567        let root = proj.path().to_str().unwrap();
568
569        // No meta on disk yet -> outdated (an unbuilt graph forces a build).
570        assert!(engine_outdated(root), "missing meta must read as outdated");
571
572        // Meta from an engine generation before the version stamp -> outdated.
573        let mut meta = PropertyGraphMetaV1 {
574            built_at: "2026-01-01T00:00:00Z".to_string(),
575            engine_version: 0,
576            ..Default::default()
577        };
578        write_meta(root, &meta).unwrap();
579        assert!(
580            engine_outdated(root),
581            "engine_version 0 must read as outdated"
582        );
583
584        // Meta stamped with the current engine -> up to date.
585        meta.engine_version = GRAPH_ENGINE_VERSION;
586        write_meta(root, &meta).unwrap();
587        assert!(
588            !engine_outdated(root),
589            "current engine_version must read as up to date"
590        );
591    }
592}