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