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