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