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 cross_source;
9mod edge;
10pub mod file_catalog;
11mod meta;
12mod node;
13mod queries;
14mod schema;
15pub mod snapshot;
16mod sync;
17
18pub use edge::{Edge, EdgeKind};
19pub use file_catalog::FileCatalogEntry;
20pub use meta::{PropertyGraphMetaV1, load_meta, meta_path, write_meta};
21pub use node::{Node, NodeKind};
22pub use queries::{
23    DependencyChain, GraphQuery, ImpactResult, edge_weight, file_connectivity, related_files,
24};
25pub use sync::{mirror_index, parse_symbol_metadata, populate_from_project_index};
26
27use rusqlite::Connection;
28use std::path::{Path, PathBuf};
29
30/// Resolve the directory for graph.db and graph.meta.json.
31///
32/// Uses `$LEAN_CTX_DATA_DIR/graphs/<project_hash>/` (consistent with
33/// `ProjectIndex::index_dir`).  Falls back to `<project>/.lean-ctx/`
34/// only when the global data directory cannot be resolved.
35pub fn graph_dir(project_root: &str) -> PathBuf {
36    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
37        let normalized = crate::core::graph_index::normalize_project_root(project_root);
38        let hash = crate::core::project_hash::hash_project_root(&normalized);
39        data_dir.join("graphs").join(hash)
40    } else {
41        Path::new(project_root).join(".lean-ctx")
42    }
43}
44
45/// Transparently migrate graph.db and graph.meta.json from the old
46/// per-project `.lean-ctx/` directory to the new `$DATA_DIR/graphs/` path.
47fn migrate_if_needed(project_root: &str, new_dir: &Path) {
48    let old_dir = Path::new(project_root).join(".lean-ctx");
49    if old_dir == new_dir {
50        return;
51    }
52    for file in &["graph.db", "graph.meta.json"] {
53        let old = old_dir.join(file);
54        let new = new_dir.join(file);
55        if old.exists()
56            && !new.exists()
57            && std::fs::rename(&old, &new).is_err()
58            && std::fs::copy(&old, &new).is_ok()
59        {
60            let _ = std::fs::remove_file(&old);
61        }
62    }
63}
64
65/// Property-graph engine generation. Bump whenever edge extraction changes
66/// (e.g. the `type_ref` edges that connect C#/Java same-namespace consumers to
67/// their definers, GH #398) so an existing graph built by an older engine is
68/// transparently rebuilt on the next query instead of being served without the
69/// new edges. Graphs whose `graph.meta.json` predates this stamp deserialize to
70/// engine version `0`, so the first query after an upgrade rebuilds once.
71///
72/// History:
73/// - `2`: `type_ref` edges added to the `ctx_impact` builder (v3.8.3).
74/// - `3`: `type_ref` edges moved into the durable `graph_index` mirror so a
75///   background reindex can no longer wipe the C# blast radius (GH #398); every
76///   graph stamped by an engine that predates the mirror fix must rebuild.
77/// - `4`: same-package `type_ref` edges extended to Go (directory-scoped) and
78///   Kotlin (GH #398 bug class); graphs built before they existed must rebuild.
79pub const GRAPH_ENGINE_VERSION: u32 = 4;
80
81/// `true` when the persisted graph was built by an engine older than
82/// [`GRAPH_ENGINE_VERSION`] — or predates the version stamp entirely (missing or
83/// unreadable meta) — and must therefore be rebuilt before its edges can be
84/// trusted. Callers pair this with a node-count check: an empty graph is rebuilt
85/// regardless; a non-empty-but-outdated graph is rebuilt by this gate.
86pub fn engine_outdated(project_root: &str) -> bool {
87    load_meta(project_root).is_none_or(|m| m.engine_version < GRAPH_ENGINE_VERSION)
88}
89
90pub struct CodeGraph {
91    conn: Connection,
92    db_path: PathBuf,
93}
94
95impl CodeGraph {
96    pub fn open(project_root: &str) -> anyhow::Result<Self> {
97        let db_dir = graph_dir(project_root);
98        std::fs::create_dir_all(&db_dir)?;
99        migrate_if_needed(project_root, &db_dir);
100        let db_path = db_dir.join("graph.db");
101        let conn = Connection::open(&db_path)?;
102        conn.busy_timeout(std::time::Duration::from_secs(5))?;
103        schema::initialize(&conn)?;
104        Ok(Self { conn, db_path })
105    }
106
107    pub fn open_in_memory() -> anyhow::Result<Self> {
108        let conn = Connection::open_in_memory()?;
109        schema::initialize(&conn)?;
110        Ok(Self {
111            conn,
112            db_path: PathBuf::from(":memory:"),
113        })
114    }
115
116    pub fn db_path(&self) -> &Path {
117        &self.db_path
118    }
119
120    pub fn connection(&self) -> &Connection {
121        &self.conn
122    }
123
124    pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
125        node::upsert(&self.conn, node)
126    }
127
128    pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
129        edge::upsert(&self.conn, edge)
130    }
131
132    pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
133        node::get_by_path(&self.conn, file_path)
134    }
135
136    pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
137        node::get_by_symbol(&self.conn, name, file_path)
138    }
139
140    pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
141        node::remove_by_file(&self.conn, file_path)
142    }
143
144    pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
145        edge::from_node(&self.conn, node_id)
146    }
147
148    pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
149        edge::to_node(&self.conn, node_id)
150    }
151
152    pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
153        queries::dependents(&self.conn, file_path)
154    }
155
156    pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
157        queries::dependencies(&self.conn, file_path)
158    }
159
160    pub fn impact_analysis(
161        &self,
162        file_path: &str,
163        max_depth: usize,
164    ) -> anyhow::Result<ImpactResult> {
165        queries::impact_analysis(&self.conn, file_path, max_depth)
166    }
167
168    pub fn dependency_chain(
169        &self,
170        from: &str,
171        to: &str,
172    ) -> anyhow::Result<Option<DependencyChain>> {
173        queries::dependency_chain(&self.conn, from, to)
174    }
175
176    pub fn related_files(
177        &self,
178        file_path: &str,
179        limit: usize,
180    ) -> anyhow::Result<Vec<(String, f64)>> {
181        queries::related_files(&self.conn, file_path, limit)
182    }
183
184    pub fn file_connectivity(
185        &self,
186        file_path: &str,
187    ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
188        queries::file_connectivity(&self.conn, file_path)
189    }
190
191    pub fn node_count(&self) -> anyhow::Result<usize> {
192        node::count(&self.conn)
193    }
194
195    pub fn edge_count(&self) -> anyhow::Result<usize> {
196        edge::count(&self.conn)
197    }
198
199    /// Persist a cross-source edge (code file ↔ external source URI) into the
200    /// dedicated `cross_source_edges` table. Keeps the higher weight on conflict
201    /// so repeated provider ingests don't downgrade an established link (#682).
202    pub fn upsert_cross_source_edge(
203        &self,
204        from: &str,
205        to: &str,
206        kind: &str,
207        weight: f32,
208    ) -> anyhow::Result<()> {
209        cross_source::upsert(&self.conn, from, to, kind, weight)
210    }
211
212    /// All cross-source edges as `IndexEdge`s, ready for `cross_source_hints`.
213    pub fn all_cross_source_edges(&self) -> Vec<crate::core::graph_index::IndexEdge> {
214        cross_source::all(&self.conn).unwrap_or_default()
215    }
216
217    pub fn cross_source_edge_count(&self) -> anyhow::Result<usize> {
218        cross_source::count(&self.conn)
219    }
220
221    pub fn clear(&self) -> anyhow::Result<()> {
222        self.conn.execute_batch(
223            "DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog; \
224             DELETE FROM cross_source_edges;",
225        )?;
226        Ok(())
227    }
228
229    /// Clear only the code graph (nodes, edges, file catalog), preserving
230    /// provider `cross_source_edges`. Used by the graph_index→PG mirror (#682.1)
231    /// so rebuilding the code graph never drops lateral provider hints, which
232    /// live in their own table and are repopulated on a separate ingest cycle.
233    pub fn clear_code_graph(&self) -> anyhow::Result<()> {
234        self.conn
235            .execute_batch("DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog;")?;
236        Ok(())
237    }
238
239    pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
240        file_catalog::upsert(&self.conn, entry)
241    }
242
243    pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
244        file_catalog::get(&self.conn, path)
245    }
246
247    pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
248        file_catalog::count(&self.conn)
249    }
250
251    pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
252        file_catalog::all_paths(&self.conn)
253    }
254
255    pub fn find_symbols(
256        &self,
257        name: &str,
258        file_filter: Option<&str>,
259        kind_filter: Option<&str>,
260    ) -> anyhow::Result<Vec<Node>> {
261        node::find_symbols(&self.conn, name, file_filter, kind_filter)
262    }
263
264    /// Files that define a symbol named exactly `name` (GH #398 symbol-name
265    /// `ctx_impact analyze` fallback). Backed by `node::resolve_symbol_def_files`.
266    pub fn resolve_symbol_def_files(&self, name: &str) -> anyhow::Result<Vec<String>> {
267        node::resolve_symbol_def_files(&self.conn, name)
268    }
269
270    pub fn symbol_count(&self) -> anyhow::Result<usize> {
271        node::symbol_count(&self.conn)
272    }
273
274    /// Count of `file` nodes (accurate on both builder paths). Backed by
275    /// `node::file_count`.
276    pub fn file_node_count(&self) -> anyhow::Result<usize> {
277        node::file_count(&self.conn)
278    }
279
280    /// Every symbol node with its line span (unfiltered). Backend for the
281    /// call-graph symbol table after the `graph_index` teardown (#696).
282    pub fn all_symbols(&self) -> anyhow::Result<Vec<Node>> {
283        node::all_symbols(&self.conn)
284    }
285
286    pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
287        node::all_edges_flat(&self.conn)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::core::data_dir::test_env_lock;
295
296    fn test_graph() -> CodeGraph {
297        CodeGraph::open_in_memory().unwrap()
298    }
299
300    #[test]
301    fn create_and_query_nodes() {
302        let g = test_graph();
303
304        let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
305        assert!(id > 0);
306
307        let found = g.get_node_by_path("src/main.rs").unwrap();
308        assert!(found.is_some());
309        assert_eq!(found.unwrap().file_path, "src/main.rs");
310    }
311
312    #[test]
313    fn create_and_query_edges() {
314        let g = test_graph();
315
316        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
317        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
318
319        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
320
321        let from_a = g.edges_from(a).unwrap();
322        assert_eq!(from_a.len(), 1);
323        assert_eq!(from_a[0].target_id, b);
324
325        let to_b = g.edges_to(b).unwrap();
326        assert_eq!(to_b.len(), 1);
327        assert_eq!(to_b[0].source_id, a);
328    }
329
330    #[test]
331    fn dependents_query() {
332        let g = test_graph();
333
334        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
335        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
336        let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
337
338        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
339            .unwrap();
340        g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
341            .unwrap();
342
343        let deps = g.dependents("src/lib.rs").unwrap();
344        assert_eq!(deps.len(), 2);
345        assert!(deps.contains(&"src/main.rs".to_string()));
346        assert!(deps.contains(&"src/utils.rs".to_string()));
347    }
348
349    #[test]
350    fn dependencies_query() {
351        let g = test_graph();
352
353        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
354        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
355        let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
356
357        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
358            .unwrap();
359        g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
360            .unwrap();
361
362        let deps = g.dependencies("src/main.rs").unwrap();
363        assert_eq!(deps.len(), 2);
364    }
365
366    #[test]
367    #[allow(clippy::many_single_char_names)] // graph test nodes: a, b, c, d, e
368    fn impact_analysis_depth() {
369        let g = test_graph();
370
371        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
372        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
373        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
374        let d = g.upsert_node(&Node::file("d.rs")).unwrap();
375
376        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
377        g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
378        g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
379
380        let impact = g.impact_analysis("a.rs", 2).unwrap();
381        assert!(impact.affected_files.contains(&"b.rs".to_string()));
382        assert!(impact.affected_files.contains(&"c.rs".to_string()));
383        assert!(!impact.affected_files.contains(&"d.rs".to_string()));
384
385        let deep = g.impact_analysis("a.rs", 10).unwrap();
386        assert!(deep.affected_files.contains(&"d.rs".to_string()));
387    }
388
389    #[test]
390    fn upsert_idempotent() {
391        let g = test_graph();
392
393        let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
394        let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
395        assert_eq!(id1, id2);
396        assert_eq!(g.node_count().unwrap(), 1);
397    }
398
399    #[test]
400    fn remove_file_cascades() {
401        let g = test_graph();
402
403        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
404        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
405        let sym = g
406            .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
407            .unwrap();
408
409        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
410        g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
411
412        g.remove_file_nodes("src/a.rs").unwrap();
413
414        assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
415        assert_eq!(g.edge_count().unwrap(), 0);
416    }
417
418    #[test]
419    fn dependency_chain_found() {
420        let g = test_graph();
421
422        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
423        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
424        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
425
426        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
427        g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
428
429        let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
430        assert!(chain.is_some());
431        let chain = chain.unwrap();
432        assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
433    }
434
435    #[test]
436    fn counts() {
437        let g = test_graph();
438        assert_eq!(g.node_count().unwrap(), 0);
439        assert_eq!(g.edge_count().unwrap(), 0);
440
441        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
442        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
443        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
444
445        assert_eq!(g.node_count().unwrap(), 2);
446        assert_eq!(g.edge_count().unwrap(), 1);
447    }
448
449    #[test]
450    fn multi_edge_dependents() {
451        let g = test_graph();
452
453        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
454        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
455        let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
456
457        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
458        g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
459
460        let deps = g.dependents("src/a.rs").unwrap();
461        assert_eq!(deps.len(), 2);
462        assert!(deps.contains(&"src/b.rs".to_string()));
463        assert!(deps.contains(&"src/c.rs".to_string()));
464    }
465
466    #[test]
467    fn multi_edge_impact_analysis() {
468        let g = test_graph();
469
470        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
471        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
472        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
473
474        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
475        g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
476
477        let impact = g.impact_analysis("a.rs", 10).unwrap();
478        assert!(impact.affected_files.contains(&"b.rs".to_string()));
479        assert!(impact.affected_files.contains(&"c.rs".to_string()));
480    }
481
482    #[test]
483    fn related_files_scored() {
484        let g = test_graph();
485
486        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
487        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
488        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
489
490        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
491        g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
492        g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
493
494        let related = g.related_files("a.rs", 10).unwrap();
495        assert_eq!(related.len(), 2);
496        let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
497        let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
498        assert!(
499            b_score > c_score,
500            "b.rs has imports+calls, should rank higher than c.rs with type_ref"
501        );
502    }
503
504    #[test]
505    fn graph_dir_uses_data_dir_when_set() {
506        let tmp = tempfile::tempdir().unwrap();
507        let project = tmp.path().join("myproject");
508        std::fs::create_dir_all(&project).unwrap();
509
510        let data_dir = tmp.path().join("data");
511        std::fs::create_dir_all(&data_dir).unwrap();
512
513        let _guard = test_env_lock();
514        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
515
516        let dir = graph_dir(project.to_str().unwrap());
517        assert!(dir.starts_with(&data_dir));
518        assert!(dir.to_string_lossy().contains("graphs"));
519
520        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
521    }
522
523    #[test]
524    fn graph_dir_returns_consistent_hash_dir() {
525        let tmp = tempfile::tempdir().unwrap();
526        let project = tmp.path().join("hash_project");
527        std::fs::create_dir_all(&project).unwrap();
528
529        let data_dir = tmp.path().join("data2");
530        std::fs::create_dir_all(&data_dir).unwrap();
531
532        let _guard = test_env_lock();
533        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
534
535        let dir1 = graph_dir(project.to_str().unwrap());
536        let dir2 = graph_dir(project.to_str().unwrap());
537        assert_eq!(dir1, dir2, "graph_dir should be deterministic");
538        assert!(dir1.to_string_lossy().contains("graphs"));
539
540        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
541    }
542
543    #[test]
544    fn migration_moves_old_files() {
545        let tmp = tempfile::tempdir().unwrap();
546        let project = tmp.path().join("migtest");
547        let old_dir = project.join(".lean-ctx");
548        std::fs::create_dir_all(&old_dir).unwrap();
549        std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
550        std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
551
552        let new_dir = tmp.path().join("newloc");
553        std::fs::create_dir_all(&new_dir).unwrap();
554
555        migrate_if_needed(project.to_str().unwrap(), &new_dir);
556
557        assert!(new_dir.join("graph.db").exists());
558        assert!(new_dir.join("graph.meta.json").exists());
559        assert!(!old_dir.join("graph.db").exists());
560        assert!(!old_dir.join("graph.meta.json").exists());
561        assert_eq!(
562            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
563            "old-db-content"
564        );
565    }
566
567    #[test]
568    fn migration_skips_when_new_exists() {
569        let tmp = tempfile::tempdir().unwrap();
570        let project = tmp.path().join("skiptest");
571        let old_dir = project.join(".lean-ctx");
572        std::fs::create_dir_all(&old_dir).unwrap();
573        std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
574
575        let new_dir = tmp.path().join("newloc2");
576        std::fs::create_dir_all(&new_dir).unwrap();
577        std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
578
579        migrate_if_needed(project.to_str().unwrap(), &new_dir);
580
581        assert_eq!(
582            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
583            "already-there"
584        );
585        assert!(old_dir.join("graph.db").exists());
586    }
587
588    #[test]
589    fn open_with_data_dir() {
590        let tmp = tempfile::tempdir().unwrap();
591        let project = tmp.path().join("opentest");
592        std::fs::create_dir_all(&project).unwrap();
593
594        let data_dir = tmp.path().join("xdata");
595        std::fs::create_dir_all(&data_dir).unwrap();
596
597        let _guard = test_env_lock();
598        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
599
600        let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
601        assert!(g.db_path().starts_with(&data_dir));
602        assert!(g.db_path().to_string_lossy().contains("graph.db"));
603
604        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
605    }
606
607    #[test]
608    fn meta_path_uses_graph_dir() {
609        let tmp = tempfile::tempdir().unwrap();
610        let project = tmp.path().join("metatest");
611        std::fs::create_dir_all(&project).unwrap();
612
613        let data_dir = tmp.path().join("mdata");
614        std::fs::create_dir_all(&data_dir).unwrap();
615
616        let _guard = test_env_lock();
617        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
618
619        let mp = meta::meta_path(project.to_str().unwrap());
620        assert!(mp.starts_with(&data_dir));
621        assert!(mp.to_string_lossy().contains("graph.meta.json"));
622
623        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
624    }
625
626    #[test]
627    fn engine_outdated_flags_old_and_missing_meta() {
628        let _iso = crate::core::data_dir::isolated_data_dir();
629        let proj = tempfile::tempdir().unwrap();
630        let root = proj.path().to_str().unwrap();
631
632        // No meta on disk yet -> outdated (an unbuilt graph forces a build).
633        assert!(engine_outdated(root), "missing meta must read as outdated");
634
635        // Meta from an engine generation before the version stamp -> outdated.
636        let mut meta = PropertyGraphMetaV1 {
637            built_at: "2026-01-01T00:00:00Z".to_string(),
638            engine_version: 0,
639            ..Default::default()
640        };
641        write_meta(root, &meta).unwrap();
642        assert!(
643            engine_outdated(root),
644            "engine_version 0 must read as outdated"
645        );
646
647        // Meta stamped with the current engine -> up to date.
648        meta.engine_version = GRAPH_ENGINE_VERSION;
649        write_meta(root, &meta).unwrap();
650        assert!(
651            !engine_outdated(root),
652            "current engine_version must read as up to date"
653        );
654    }
655}