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