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;
12pub mod node;
13mod path_id;
14mod queries;
15mod schema;
16pub mod semantic_cache;
17pub(crate) mod served;
18pub mod snapshot;
19mod sync;
20
21pub use edge::{Edge, EdgeKind};
22pub use file_catalog::FileCatalogEntry;
23pub use meta::{PropertyGraphMetaV1, load_meta, meta_path, write_meta};
24pub use node::{Node, NodeKind};
25pub use queries::{
26    DependencyChain, GraphQuery, ImpactResult, Served, edge_weight, file_connectivity,
27    related_files,
28};
29pub use semantic_cache::CachedResolution;
30pub use served::GraphSource;
31pub use sync::{mirror_index, parse_symbol_metadata, populate_from_project_index};
32
33use rusqlite::Connection;
34use std::path::{Path, PathBuf};
35
36/// Resolve the directory for graph.db and graph.meta.json.
37///
38/// Uses `$LEAN_CTX_DATA_DIR/graphs/<project_hash>/` (consistent with
39/// `ProjectIndex::index_dir`).  Falls back to `<project>/.lean-ctx/`
40/// only when the global data directory cannot be resolved.
41pub fn graph_dir(project_root: &str) -> PathBuf {
42    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
43        let normalized = crate::core::graph_index::normalize_project_root(project_root);
44        let hash = crate::core::project_hash::hash_project_root(&normalized);
45        data_dir.join("graphs").join(hash)
46    } else {
47        Path::new(project_root).join(".lean-ctx")
48    }
49}
50
51/// Transparently migrate graph.db and graph.meta.json from the old
52/// per-project `.lean-ctx/` directory to the new `$DATA_DIR/graphs/` path.
53fn migrate_if_needed(project_root: &str, new_dir: &Path) {
54    let old_dir = Path::new(project_root).join(".lean-ctx");
55    if old_dir == new_dir {
56        return;
57    }
58    for file in &["graph.db", "graph.meta.json"] {
59        let old = old_dir.join(file);
60        let new = new_dir.join(file);
61        if old.exists()
62            && !new.exists()
63            && std::fs::rename(&old, &new).is_err()
64            && std::fs::copy(&old, &new).is_ok()
65        {
66            let _ = std::fs::remove_file(&old);
67        }
68    }
69}
70
71/// Property-graph engine generation. Bump whenever edge extraction changes
72/// (e.g. the `type_ref` edges that connect C#/Java same-namespace consumers to
73/// their definers, GH #398) so an existing graph built by an older engine is
74/// transparently rebuilt on the next query instead of being served without the
75/// new edges. Graphs whose `graph.meta.json` predates this stamp deserialize to
76/// engine version `0`, so the first query after an upgrade rebuilds once.
77///
78/// History:
79/// - `2`: `type_ref` edges added to the `ctx_impact` builder (v3.8.3).
80/// - `3`: `type_ref` edges moved into the durable `graph_index` mirror so a
81///   background reindex can no longer wipe the C# blast radius (GH #398); every
82///   graph stamped by an engine that predates the mirror fix must rebuild.
83/// - `4`: same-package `type_ref` edges extended to Go (directory-scoped) and
84///   Kotlin (GH #398 bug class); graphs built before they existed must rebuild.
85/// - `5`: PropertyGraph file paths use interned IDs; pre-ID databases rebuild.
86/// - `6`: `calls` edges carry typed evidence and are no longer guessed (the
87///   `ctx_impact` builder bound ambiguous callees to the alphabetically first
88///   definition); graphs holding unannotated guessed edges must rebuild.
89/// - `7`: `calls` edges bind only within the caller's language family (a Rust
90///   call no longer lands on a same-named shell function); graphs holding
91///   such cross-language edges must rebuild.
92/// - `8`: path calls (`crate::config_io::write(..)`) bind to the file their
93///   own module path names; graphs built without those `calls` edges must
94///   rebuild.
95pub const GRAPH_ENGINE_VERSION: u32 = 8;
96
97/// `true` when the persisted graph was built by another engine generation than
98/// [`GRAPH_ENGINE_VERSION`] — older, NEWER, or before the version stamp existed
99/// (missing or unreadable meta) — and must therefore be rebuilt before its
100/// edges can be trusted. A newer stamp is what an installation finds after a
101/// rollback to an earlier release: that graph holds edges this engine's rules
102/// did not produce and cannot interpret, so it is rebuilt, not read. Callers
103/// pair this with a node-count check: an empty graph is rebuilt regardless; a
104/// non-empty graph of another generation is rebuilt by this gate.
105pub fn engine_outdated(project_root: &str) -> bool {
106    load_meta(project_root).is_none_or(|m| m.engine_version != GRAPH_ENGINE_VERSION)
107}
108
109/// How long a status read waits for a graph another process holds locked.
110const STORED_COUNTS_BUSY: std::time::Duration = std::time::Duration::from_millis(250);
111
112/// Row counts of a persisted graph, read for status reporting only.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct StoredCounts {
115    /// Rows of the file catalog: the files the graph index holds.
116    pub files: u64,
117    /// Rows of the node table.
118    pub nodes: u64,
119}
120
121/// What `project_root`'s persisted graph holds, without serving anything
122/// from it (#2179). Status readers (`doctor`, `index status`) used to
123/// rebuild the whole index through the serving surface just to count its
124/// files — a source check per catalogued file — and to open the store
125/// through [`CodeGraph::open`], which migrates and creates schema. This
126/// opens the existing `graph.db` only (never creates it), reads no schema
127/// it would change (`query_only`), and waits at most `STORED_COUNTS_BUSY`
128/// for a lock another process holds: `None` when there is no graph, its
129/// schema is not this engine's, or it stays busy.
130pub fn stored_counts(project_root: &str) -> Option<StoredCounts> {
131    let db_path = graph_dir(project_root).join("graph.db");
132    if !db_path.is_file() {
133        return None;
134    }
135    stored_counts_at(&db_path, STORED_COUNTS_BUSY)
136}
137
138fn stored_counts_at(db_path: &Path, busy: std::time::Duration) -> Option<StoredCounts> {
139    use rusqlite::OpenFlags;
140    let conn = Connection::open_with_flags(
141        db_path,
142        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
143    )
144    .ok()?;
145    conn.busy_timeout(busy).ok()?;
146    conn.execute_batch("PRAGMA query_only = ON;").ok()?;
147    let count = |table: &str| {
148        conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
149            row.get::<_, i64>(0)
150        })
151        .ok()
152        .and_then(|n| u64::try_from(n).ok())
153    };
154    Some(StoredCounts {
155        files: count("file_catalog")?,
156        nodes: count("nodes")?,
157    })
158}
159
160/// Whether an open error is a transient SQLite lock (BUSY/LOCKED) that a brief
161/// retry can clear — notably the `PRAGMA journal_mode=WAL` and initial-DDL races
162/// that SQLite reports *without* invoking the busy handler (so `busy_timeout`
163/// does not cover them).
164fn is_transient_lock(err: &anyhow::Error) -> bool {
165    use rusqlite::ErrorCode;
166    if let Some(rusqlite::Error::SqliteFailure(e, _)) = err.downcast_ref::<rusqlite::Error>()
167        && matches!(e.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
168    {
169        return true;
170    }
171    // The schema initializer may surface the same condition wrapped; match the
172    // full error chain's text as a fallback.
173    let msg = format!("{err:#}").to_ascii_lowercase();
174    msg.contains("database is locked") || msg.contains("table is locked")
175}
176
177pub struct CodeGraph {
178    conn: Connection,
179    db_path: PathBuf,
180    /// Serve-time source check of a graph opened for a project root
181    /// (`served`, G04); `None` for an in-memory graph.
182    gate: Option<served::GraphGate>,
183}
184
185impl CodeGraph {
186    pub fn open(project_root: &str) -> anyhow::Result<Self> {
187        let db_dir = graph_dir(project_root);
188        std::fs::create_dir_all(&db_dir)?;
189        migrate_if_needed(project_root, &db_dir);
190        let db_path = db_dir.join("graph.db");
191
192        // Concurrent opens race on `PRAGMA journal_mode=WAL` and the initial DDL,
193        // which SQLite reports as SQLITE_BUSY without invoking the busy handler
194        // (`busy_timeout` therefore does not apply). Deeper addon integration can
195        // have a gateway ingest thread and the main session's graph build open
196        // the same db at once (#1102); retry briefly so neither silently loses
197        // its writes. Once WAL is recorded in the file header, opens stop racing.
198        const MAX_ATTEMPTS: u32 = 12;
199        let mut attempt = 0;
200        loop {
201            attempt += 1;
202            match Self::try_open(&db_path) {
203                Ok(mut graph) => {
204                    graph.gate = Some(served::GraphGate::for_root(project_root));
205                    return Ok(graph);
206                }
207                Err(e) if attempt < MAX_ATTEMPTS && is_transient_lock(&e) => {
208                    std::thread::sleep(std::time::Duration::from_millis(40 * u64::from(attempt)));
209                }
210                Err(e) => return Err(e),
211            }
212        }
213    }
214
215    /// One open attempt: connect, register the busy handler, ensure the schema.
216    fn try_open(db_path: &Path) -> anyhow::Result<Self> {
217        let conn = Connection::open(db_path)?;
218        conn.busy_timeout(std::time::Duration::from_secs(5))?;
219        schema::initialize(&conn)?;
220        Ok(Self {
221            conn,
222            db_path: db_path.to_path_buf(),
223            gate: None,
224        })
225    }
226
227    pub fn open_in_memory() -> anyhow::Result<Self> {
228        let conn = Connection::open_in_memory()?;
229        schema::initialize(&conn)?;
230        Ok(Self {
231            conn,
232            db_path: PathBuf::from(":memory:"),
233            gate: None,
234        })
235    }
236
237    pub fn db_path(&self) -> &Path {
238        &self.db_path
239    }
240
241    pub fn connection(&self) -> &Connection {
242        &self.conn
243    }
244
245    pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
246        node::upsert(&self.conn, node)
247    }
248
249    pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
250        edge::upsert(&self.conn, edge)
251    }
252
253    pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
254        node::get_by_path(&self.conn, file_path)
255    }
256
257    pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
258        node::get_by_symbol(&self.conn, name, file_path)
259    }
260
261    pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
262        node::remove_by_file(&self.conn, file_path)
263    }
264
265    pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
266        edge::from_node(&self.conn, node_id)
267    }
268
269    pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
270        edge::to_node(&self.conn, node_id)
271    }
272
273    // The five queries below run under this graph's serve-time source check
274    // (`served`, G04): a file that is not served now is neither returned nor
275    // walked through, and contributes no weight, count or rank slot.
276
277    pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
278        Ok(self.served_neighbours(file_path, queries::dependents(&self.conn, file_path)?))
279    }
280
281    pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
282        Ok(self.served_neighbours(file_path, queries::dependencies(&self.conn, file_path)?))
283    }
284
285    pub fn impact_analysis(
286        &self,
287        file_path: &str,
288        max_depth: usize,
289    ) -> anyhow::Result<ImpactResult> {
290        queries::impact_analysis(&self.conn, file_path, max_depth, &|path: &str| {
291            self.serves_file(path)
292        })
293    }
294
295    pub fn dependency_chain(
296        &self,
297        from: &str,
298        to: &str,
299    ) -> anyhow::Result<Option<DependencyChain>> {
300        queries::dependency_chain(&self.conn, from, to, &|path: &str| self.serves_file(path))
301    }
302
303    pub fn related_files(
304        &self,
305        file_path: &str,
306        limit: usize,
307    ) -> anyhow::Result<Vec<(String, f64)>> {
308        queries::related_files(&self.conn, file_path, limit, &|path: &str| {
309            self.serves_file(path)
310        })
311    }
312
313    pub fn file_connectivity(
314        &self,
315        file_path: &str,
316    ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
317        queries::file_connectivity(&self.conn, file_path, &|path: &str| self.serves_file(path))
318    }
319
320    pub fn node_count(&self) -> anyhow::Result<usize> {
321        node::count(&self.conn)
322    }
323
324    pub fn edge_count(&self) -> anyhow::Result<usize> {
325        edge::count(&self.conn)
326    }
327
328    /// Cached semantic answer for a call site, if the caller is unchanged and
329    /// every file the answer derives from — the caller and each target — is
330    /// served now (`semantic_cache::lookup_served`, G04). A withheld answer
331    /// is a miss: the backend is asked again.
332    pub fn semantic_lookup(
333        &self,
334        site: semantic_cache::SiteKey<'_>,
335        caller_hash: &str,
336    ) -> anyhow::Result<Option<CachedResolution>> {
337        semantic_cache::lookup_served(&self.conn, site, caller_hash, |file| self.serves_file(file))
338    }
339
340    /// Keeps a semantic answer when its persisted text passes storage
341    /// admission under `admission` — one policy snapshot per enrichment run,
342    /// taken where the run starts, not per row. `Ok(false)`: admission
343    /// refused the row and nothing was written.
344    pub fn semantic_store(
345        &self,
346        site: semantic_cache::SiteKey<'_>,
347        caller_hash: &str,
348        value: &CachedResolution,
349        admission: &crate::core::context_admission::stores::StoreAdmission,
350    ) -> anyhow::Result<bool> {
351        semantic_cache::store_admitted(&self.conn, site, caller_hash, value, admission)
352    }
353
354    /// Whether a semantic backend has answered for this graph at all.
355    pub fn has_semantic_answers(&self) -> anyhow::Result<bool> {
356        semantic_cache::any(&self.conn)
357    }
358
359    /// Removes cached answers for vanished or changed caller files.
360    pub fn semantic_prune(
361        &self,
362        live_hashes: &std::collections::HashMap<String, String>,
363    ) -> anyhow::Result<usize> {
364        semantic_cache::prune(&self.conn, live_hashes)
365    }
366
367    /// File→file edges of `kind` with their metadata, as
368    /// `(source path, target path, metadata)` — sorted for determinism.
369    pub fn file_edges_of_kind(
370        &self,
371        kind: &EdgeKind,
372    ) -> anyhow::Result<Vec<(String, String, Option<String>)>> {
373        edge::file_edges_of_kind(&self.conn, kind)
374    }
375
376    /// Upserts an edge carrying typed evidence. The incoming producer's
377    /// contribution replaces only its own earlier one; other producers'
378    /// contributions on the same edge are kept
379    /// ([`crate::core::semantic::EdgeEvidence::merge`]).
380    pub fn upsert_edge_with_evidence(
381        &self,
382        source_id: i64,
383        target_id: i64,
384        kind: &EdgeKind,
385        evidence: &crate::core::semantic::EdgeEvidence,
386    ) -> anyhow::Result<()> {
387        use crate::core::semantic::EdgeEvidence;
388        self.atomically(|| {
389            let existing = edge::metadata_of(&self.conn, source_id, target_id, kind)?;
390            let merged = EdgeEvidence::merge(
391                EdgeEvidence::from_metadata(existing.as_deref()),
392                evidence.clone(),
393            );
394            edge::upsert(
395                &self.conn,
396                &Edge::new(source_id, target_id, kind.clone()).with_metadata(&merged.to_metadata()),
397            )
398        })
399    }
400
401    /// Runs a read-modify-write as one unit. Another process (daemon vs. MCP
402    /// server) may update the same edge concurrently; `BEGIN IMMEDIATE` takes
403    /// the write lock before the read, so no contribution is lost. Inside an
404    /// outer transaction that one already provides atomicity.
405    fn atomically<T>(&self, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
406        if !self.conn.is_autocommit() {
407            return f();
408        }
409        self.conn.execute_batch("BEGIN IMMEDIATE")?;
410        let outcome = f().and_then(|value| {
411            self.conn.execute_batch("COMMIT")?;
412            Ok(value)
413        });
414        // A failed body *or* a failed COMMIT must not leave the connection
415        // inside a transaction: the next call would see !is_autocommit, skip
416        // BEGIN and never commit, while holding the writer lock.
417        if outcome.is_err() && !self.conn.is_autocommit() {
418            let _ = self.conn.execute_batch("ROLLBACK");
419        }
420        outcome
421    }
422
423    /// Withdraws `origin`'s contribution from the file→file edge
424    /// `source → target`: the edge is deleted only when no other producer
425    /// still derives it. Edges without typed evidence are left untouched.
426    pub fn withdraw_file_edge(
427        &self,
428        source: &str,
429        target: &str,
430        kind: &EdgeKind,
431        origin: crate::core::semantic::EvidenceOrigin,
432    ) -> anyhow::Result<()> {
433        use crate::core::semantic::EdgeEvidence;
434        let (Some(s), Some(t)) = (
435            self.get_node_by_path(source)?.and_then(|n| n.id),
436            self.get_node_by_path(target)?.and_then(|n| n.id),
437        ) else {
438            return Ok(());
439        };
440        self.atomically(|| {
441            let existing = edge::metadata_of(&self.conn, s, t, kind)?;
442            let Some(evidence) = EdgeEvidence::from_metadata(existing.as_deref()) else {
443                return Ok(());
444            };
445            if !evidence.has(origin) {
446                return Ok(());
447            }
448            match evidence.without(origin) {
449                Some(rest) => edge::upsert(
450                    &self.conn,
451                    &Edge::new(s, t, kind.clone()).with_metadata(&rest.to_metadata()),
452                ),
453                None => edge::remove_file_edge(&self.conn, source, target, kind),
454            }
455        })
456    }
457
458    /// Persist a cross-source edge (code file ↔ external source URI) into the
459    /// dedicated `cross_source_edges` table. Keeps the higher weight on conflict
460    /// so repeated provider ingests don't downgrade an established link (#682).
461    pub fn upsert_cross_source_edge(
462        &self,
463        from: &str,
464        to: &str,
465        kind: &str,
466        weight: f32,
467    ) -> anyhow::Result<()> {
468        cross_source::upsert(&self.conn, from, to, kind, weight)
469    }
470
471    /// All cross-source edges as `IndexEdge`s, ready for `cross_source_hints`.
472    pub fn all_cross_source_edges(&self) -> Vec<crate::core::graph_index::IndexEdge> {
473        cross_source::all(&self.conn).unwrap_or_default()
474    }
475
476    pub fn cross_source_edge_count(&self) -> anyhow::Result<usize> {
477        cross_source::count(&self.conn)
478    }
479
480    /// Delete every cross-source edge of a given `kind`. Used by the code-health
481    /// fabric to replace its `health_hotspot` edges on each pass so resolved
482    /// hotspots never persist as stale hints. Returns the number removed.
483    pub fn delete_cross_source_edges_by_kind(&self, kind: &str) -> anyhow::Result<usize> {
484        cross_source::delete_by_kind(&self.conn, kind)
485    }
486
487    /// Delete every cross-source edge with an end under the URI `prefix`
488    /// (both directions), so a re-indexed source replaces its own edges.
489    pub fn delete_cross_source_edges_with_uri_prefix(&self, prefix: &str) -> anyhow::Result<usize> {
490        cross_source::delete_with_uri_prefix(&self.conn, prefix)
491    }
492
493    pub fn clear(&self) -> anyhow::Result<()> {
494        self.conn.execute_batch(
495            "DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog; DELETE FROM paths; \
496             DELETE FROM cross_source_edges;",
497        )?;
498        Ok(())
499    }
500
501    /// Clear only the code graph (nodes, edges, file catalog), preserving
502    /// provider `cross_source_edges`. Used by the graph_index→PG mirror (#682.1)
503    /// so rebuilding the code graph never drops lateral provider hints, which
504    /// live in their own table and are repopulated on a separate ingest cycle.
505    pub fn clear_code_graph(&self) -> anyhow::Result<()> {
506        self.conn.execute_batch(
507            "DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog; DELETE FROM paths;",
508        )?;
509        Ok(())
510    }
511
512    pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
513        file_catalog::upsert(&self.conn, entry)
514    }
515
516    pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
517        file_catalog::get(&self.conn, path)
518    }
519
520    pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
521        file_catalog::count(&self.conn)
522    }
523
524    pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
525        file_catalog::all_paths(&self.conn)
526    }
527
528    pub fn find_symbols(
529        &self,
530        name: &str,
531        file_filter: Option<&str>,
532        kind_filter: Option<&str>,
533    ) -> anyhow::Result<Vec<Node>> {
534        node::find_symbols(&self.conn, name, file_filter, kind_filter)
535    }
536
537    /// Files that define a symbol named exactly `name` (GH #398 symbol-name
538    /// `ctx_impact analyze` fallback). Backed by `node::resolve_symbol_def_files`.
539    pub fn resolve_symbol_def_files(&self, name: &str) -> anyhow::Result<Vec<String>> {
540        node::resolve_symbol_def_files(&self.conn, name)
541    }
542
543    pub fn symbol_count(&self) -> anyhow::Result<usize> {
544        node::symbol_count(&self.conn)
545    }
546
547    /// Count of `file` nodes (accurate on both builder paths). Backed by
548    /// `node::file_count`.
549    pub fn file_node_count(&self) -> anyhow::Result<usize> {
550        node::file_count(&self.conn)
551    }
552
553    /// Every symbol node with its line span (unfiltered). Backend for the
554    /// call-graph symbol table after the `graph_index` teardown (#696).
555    pub fn all_symbols(&self) -> anyhow::Result<Vec<Node>> {
556        node::all_symbols(&self.conn)
557    }
558
559    pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
560        node::all_edges_flat(&self.conn)
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::core::data_dir::test_env_lock;
568
569    fn test_graph() -> CodeGraph {
570        CodeGraph::open_in_memory().unwrap()
571    }
572
573    #[test]
574    fn create_and_query_nodes() {
575        let g = test_graph();
576
577        let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
578        assert!(id > 0);
579
580        let found = g.get_node_by_path("src/main.rs").unwrap();
581        assert!(found.is_some());
582        assert_eq!(found.unwrap().file_path, "src/main.rs");
583    }
584
585    #[test]
586    fn create_and_query_edges() {
587        let g = test_graph();
588
589        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
590        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
591
592        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
593
594        let from_a = g.edges_from(a).unwrap();
595        assert_eq!(from_a.len(), 1);
596        assert_eq!(from_a[0].target_id, b);
597
598        let to_b = g.edges_to(b).unwrap();
599        assert_eq!(to_b.len(), 1);
600        assert_eq!(to_b[0].source_id, a);
601    }
602
603    #[test]
604    fn dependents_query() {
605        let g = test_graph();
606
607        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
608        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
609        let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
610
611        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
612            .unwrap();
613        g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
614            .unwrap();
615
616        let deps = g.dependents("src/lib.rs").unwrap();
617        assert_eq!(deps.len(), 2);
618        assert!(deps.contains(&"src/main.rs".to_string()));
619        assert!(deps.contains(&"src/utils.rs".to_string()));
620    }
621
622    #[test]
623    fn dependencies_query() {
624        let g = test_graph();
625
626        let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
627        let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
628        let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
629
630        g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
631            .unwrap();
632        g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
633            .unwrap();
634
635        let deps = g.dependencies("src/main.rs").unwrap();
636        assert_eq!(deps.len(), 2);
637    }
638
639    #[test]
640    #[allow(clippy::many_single_char_names)] // graph test nodes: a, b, c, d, e
641    fn impact_analysis_depth() {
642        let g = test_graph();
643
644        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
645        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
646        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
647        let d = g.upsert_node(&Node::file("d.rs")).unwrap();
648
649        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
650        g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
651        g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
652
653        let impact = g.impact_analysis("a.rs", 2).unwrap();
654        assert!(impact.affected_files.contains(&"b.rs".to_string()));
655        assert!(impact.affected_files.contains(&"c.rs".to_string()));
656        assert!(!impact.affected_files.contains(&"d.rs".to_string()));
657
658        let deep = g.impact_analysis("a.rs", 10).unwrap();
659        assert!(deep.affected_files.contains(&"d.rs".to_string()));
660    }
661
662    /// Weighted propagation is exact and separates name-match-only reach:
663    /// a heavier path found later still propagates, and a guess on a pair
664    /// never lends its weight to that pair's evidence-backed edge.
665    #[test]
666    #[allow(clippy::many_single_char_names)] // graph test nodes
667    fn impact_propagates_the_heaviest_path_and_marks_guess_only_reach() {
668        use crate::core::semantic::{EdgeEvidence, EvidenceGrade, EvidenceOrigin};
669        let g = test_graph();
670        let id = |p: &str| g.upsert_node(&Node::file(p)).unwrap();
671        let (a, b, c, x, y, z) = (id("a"), id("b"), id("c"), id("x"), id("y"), id("z"));
672        let edge = |from, to, kind| g.upsert_edge(&Edge::new(from, to, kind)).unwrap();
673        // b → a: a name-match call guess (0.8 × 0.5 = 0.4) plus a fact
674        // (sibling, 0.25); c → b: co-change (0.35). Over all edges c gets
675        // 0.4 × 0.35 = 0.14, over facts only 0.25 × 0.35 < 0.1.
676        let guess = EdgeEvidence::new(
677            EvidenceGrade::HeuristicStructural,
678            EvidenceOrigin::Enrichment,
679            None,
680            1,
681        );
682        g.upsert_edge_with_evidence(b, a, &EdgeKind::Calls, &guess)
683            .unwrap();
684        edge(b, a, EdgeKind::Sibling);
685        edge(c, b, EdgeKind::Cochange);
686        // x is first reached lightly (sibling, 0.25), then fully via y at
687        // depth 2; only the full weight carries on to z (0.35).
688        edge(x, a, EdgeKind::Sibling);
689        edge(y, a, EdgeKind::Imports);
690        edge(x, y, EdgeKind::Imports);
691        edge(z, x, EdgeKind::Cochange);
692
693        let impact = g.impact_analysis("a", 5).unwrap();
694        assert_eq!(impact.affected_files, ["b", "c", "x", "y", "z"]);
695        assert_eq!(impact.weak_files, ["c"]);
696        assert_eq!(impact.max_depth_reached, 3);
697    }
698
699    #[test]
700    fn upsert_idempotent() {
701        let g = test_graph();
702
703        let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
704        let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
705        assert_eq!(id1, id2);
706        assert_eq!(g.node_count().unwrap(), 1);
707    }
708
709    #[test]
710    fn remove_file_cascades() {
711        let g = test_graph();
712
713        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
714        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
715        let sym = g
716            .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
717            .unwrap();
718
719        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
720        g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
721
722        g.remove_file_nodes("src/a.rs").unwrap();
723
724        assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
725        assert_eq!(g.edge_count().unwrap(), 0);
726    }
727
728    #[test]
729    fn dependency_chain_found() {
730        let g = test_graph();
731
732        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
733        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
734        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
735
736        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
737        g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
738
739        let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
740        assert!(chain.is_some());
741        let chain = chain.unwrap();
742        assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
743    }
744
745    #[test]
746    fn counts() {
747        let g = test_graph();
748        assert_eq!(g.node_count().unwrap(), 0);
749        assert_eq!(g.edge_count().unwrap(), 0);
750
751        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
752        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
753        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
754
755        assert_eq!(g.node_count().unwrap(), 2);
756        assert_eq!(g.edge_count().unwrap(), 1);
757    }
758
759    #[test]
760    fn multi_edge_dependents() {
761        let g = test_graph();
762
763        let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
764        let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
765        let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
766
767        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
768        g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
769
770        let deps = g.dependents("src/a.rs").unwrap();
771        assert_eq!(deps.len(), 2);
772        assert!(deps.contains(&"src/b.rs".to_string()));
773        assert!(deps.contains(&"src/c.rs".to_string()));
774    }
775
776    #[test]
777    fn multi_edge_impact_analysis() {
778        let g = test_graph();
779
780        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
781        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
782        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
783
784        g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
785        g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
786
787        let impact = g.impact_analysis("a.rs", 10).unwrap();
788        assert!(impact.affected_files.contains(&"b.rs".to_string()));
789        assert!(impact.affected_files.contains(&"c.rs".to_string()));
790    }
791
792    #[test]
793    fn related_files_scored() {
794        let g = test_graph();
795
796        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
797        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
798        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
799
800        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
801        g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
802        g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
803
804        let related = g.related_files("a.rs", 10).unwrap();
805        assert_eq!(related.len(), 2);
806        let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
807        let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
808        assert!(
809            b_score > c_score,
810            "b.rs has imports+calls, should rank higher than c.rs with type_ref"
811        );
812    }
813
814    /// The same call relationship stored at file and symbol granularity must
815    /// count once (at its strongest evidence), and a name-only guess must not
816    /// outrank a verified relationship.
817    #[test]
818    fn related_files_counts_each_relationship_once_weighted_by_evidence() {
819        use crate::core::semantic::{EdgeEvidence, EvidenceGrade, EvidenceOrigin};
820        let g = test_graph();
821        let a = g.upsert_node(&Node::file("a.rs")).unwrap();
822        let b = g.upsert_node(&Node::file("b.rs")).unwrap();
823        let c = g.upsert_node(&Node::file("c.rs")).unwrap();
824        let evidence = |grade| EdgeEvidence::new(grade, EvidenceOrigin::Enrichment, None, 1);
825
826        // a→b: one verified call, also mirrored by three symbol→symbol edges.
827        g.upsert_edge_with_evidence(
828            a,
829            b,
830            &EdgeKind::Calls,
831            &evidence(EvidenceGrade::VerifiedSemantic),
832        )
833        .unwrap();
834        for i in 0..3 {
835            let caller = g
836                .upsert_node(&Node::symbol(&format!("f{i}"), "a.rs", NodeKind::Symbol))
837                .unwrap();
838            let callee = g
839                .upsert_node(&Node::symbol(&format!("g{i}"), "b.rs", NodeKind::Symbol))
840                .unwrap();
841            g.upsert_edge_with_evidence(
842                caller,
843                callee,
844                &EdgeKind::Calls,
845                &evidence(EvidenceGrade::ResolvedStructural),
846            )
847            .unwrap();
848        }
849        // a→c: a heuristic name match only.
850        g.upsert_edge_with_evidence(
851            a,
852            c,
853            &EdgeKind::Calls,
854            &evidence(EvidenceGrade::HeuristicStructural),
855        )
856        .unwrap();
857
858        let related = g.related_files("a.rs", 10).unwrap();
859        let score = |p: &str| related.iter().find(|(f, _)| f == p).unwrap().1;
860        assert!(
861            (score("b.rs") - edge_weight("calls")).abs() < 1e-9,
862            "counted once"
863        );
864        assert!(score("b.rs") > score("c.rs"), "verified outranks heuristic");
865    }
866
867    #[test]
868    fn graph_dir_uses_data_dir_when_set() {
869        let tmp = tempfile::tempdir().unwrap();
870        let project = tmp.path().join("myproject");
871        std::fs::create_dir_all(&project).unwrap();
872
873        let data_dir = tmp.path().join("data");
874        std::fs::create_dir_all(&data_dir).unwrap();
875
876        let _guard = test_env_lock();
877        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
878
879        let dir = graph_dir(project.to_str().unwrap());
880        assert!(dir.starts_with(&data_dir));
881        assert!(dir.to_string_lossy().contains("graphs"));
882
883        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
884    }
885
886    #[test]
887    fn graph_dir_returns_consistent_hash_dir() {
888        let tmp = tempfile::tempdir().unwrap();
889        let project = tmp.path().join("hash_project");
890        std::fs::create_dir_all(&project).unwrap();
891
892        let data_dir = tmp.path().join("data2");
893        std::fs::create_dir_all(&data_dir).unwrap();
894
895        let _guard = test_env_lock();
896        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
897
898        let dir1 = graph_dir(project.to_str().unwrap());
899        let dir2 = graph_dir(project.to_str().unwrap());
900        assert_eq!(dir1, dir2, "graph_dir should be deterministic");
901        assert!(dir1.to_string_lossy().contains("graphs"));
902
903        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
904    }
905
906    #[test]
907    fn migration_moves_old_files() {
908        let tmp = tempfile::tempdir().unwrap();
909        let project = tmp.path().join("migtest");
910        let old_dir = project.join(".lean-ctx");
911        std::fs::create_dir_all(&old_dir).unwrap();
912        std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
913        std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
914
915        let new_dir = tmp.path().join("newloc");
916        std::fs::create_dir_all(&new_dir).unwrap();
917
918        migrate_if_needed(project.to_str().unwrap(), &new_dir);
919
920        assert!(new_dir.join("graph.db").exists());
921        assert!(new_dir.join("graph.meta.json").exists());
922        assert!(!old_dir.join("graph.db").exists());
923        assert!(!old_dir.join("graph.meta.json").exists());
924        assert_eq!(
925            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
926            "old-db-content"
927        );
928    }
929
930    #[test]
931    fn migration_skips_when_new_exists() {
932        let tmp = tempfile::tempdir().unwrap();
933        let project = tmp.path().join("skiptest");
934        let old_dir = project.join(".lean-ctx");
935        std::fs::create_dir_all(&old_dir).unwrap();
936        std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
937
938        let new_dir = tmp.path().join("newloc2");
939        std::fs::create_dir_all(&new_dir).unwrap();
940        std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
941
942        migrate_if_needed(project.to_str().unwrap(), &new_dir);
943
944        assert_eq!(
945            std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
946            "already-there"
947        );
948        assert!(old_dir.join("graph.db").exists());
949    }
950
951    #[test]
952    fn open_with_data_dir() {
953        let tmp = tempfile::tempdir().unwrap();
954        let project = tmp.path().join("opentest");
955        std::fs::create_dir_all(&project).unwrap();
956
957        let data_dir = tmp.path().join("xdata");
958        std::fs::create_dir_all(&data_dir).unwrap();
959
960        let _guard = test_env_lock();
961        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
962
963        let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
964        assert!(g.db_path().starts_with(&data_dir));
965        assert!(g.db_path().to_string_lossy().contains("graph.db"));
966
967        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
968    }
969
970    #[test]
971    fn meta_path_uses_graph_dir() {
972        let tmp = tempfile::tempdir().unwrap();
973        let project = tmp.path().join("metatest");
974        std::fs::create_dir_all(&project).unwrap();
975
976        let data_dir = tmp.path().join("mdata");
977        std::fs::create_dir_all(&data_dir).unwrap();
978
979        let _guard = test_env_lock();
980        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
981
982        let mp = meta::meta_path(project.to_str().unwrap());
983        assert!(mp.starts_with(&data_dir));
984        assert!(mp.to_string_lossy().contains("graph.meta.json"));
985
986        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
987    }
988
989    #[test]
990    fn engine_outdated_flags_old_and_missing_meta() {
991        let _iso = crate::core::data_dir::isolated_data_dir();
992        let proj = tempfile::tempdir().unwrap();
993        let root = proj.path().to_str().unwrap();
994
995        // No meta on disk yet -> outdated (an unbuilt graph forces a build).
996        assert!(engine_outdated(root), "missing meta must read as outdated");
997
998        // Meta from an engine generation before the version stamp -> outdated.
999        let mut meta = PropertyGraphMetaV1 {
1000            built_at: "2026-01-01T00:00:00Z".to_string(),
1001            engine_version: 0,
1002            ..Default::default()
1003        };
1004        write_meta(root, &meta).unwrap();
1005        assert!(
1006            engine_outdated(root),
1007            "engine_version 0 must read as outdated"
1008        );
1009
1010        // Meta stamped with the current engine -> up to date.
1011        meta.engine_version = GRAPH_ENGINE_VERSION;
1012        write_meta(root, &meta).unwrap();
1013        assert!(
1014            !engine_outdated(root),
1015            "current engine_version must read as up to date"
1016        );
1017
1018        // Meta stamped by a later engine (the state after a rollback) -> rebuilt.
1019        meta.engine_version = GRAPH_ENGINE_VERSION + 1;
1020        write_meta(root, &meta).unwrap();
1021        assert!(
1022            engine_outdated(root),
1023            "a graph of a newer engine must not be read by this one"
1024        );
1025    }
1026
1027    fn catalog_entry(path: &str) -> FileCatalogEntry {
1028        FileCatalogEntry {
1029            path: path.to_string(),
1030            hash: format!("hash-{path}"),
1031            language: "rs".to_string(),
1032            line_count: 1,
1033            token_count: 1,
1034            exports: Vec::new(),
1035            summary: String::new(),
1036        }
1037    }
1038
1039    /// #2179: a status read counts what the store holds, never creates a
1040    /// graph that is not there.
1041    #[test]
1042    fn stored_counts_reads_rows_and_never_creates_a_graph() {
1043        let _iso = crate::core::data_dir::isolated_data_dir();
1044        let proj = tempfile::tempdir().unwrap();
1045        let root = proj.path().to_str().unwrap();
1046
1047        assert_eq!(stored_counts(root), None);
1048        assert!(
1049            !graph_dir(root).join("graph.db").exists(),
1050            "a status read must not create graph.db"
1051        );
1052
1053        let g = CodeGraph::open(root).unwrap();
1054        g.upsert_file_catalog(&catalog_entry("src/a.rs")).unwrap();
1055        g.upsert_file_catalog(&catalog_entry("src/b.rs")).unwrap();
1056        g.upsert_node(&Node::file("src/a.rs")).unwrap();
1057        drop(g);
1058
1059        assert_eq!(
1060            stored_counts(root),
1061            Some(StoredCounts { files: 2, nodes: 1 })
1062        );
1063    }
1064
1065    /// #2179: a graph another process holds locked is answered within the
1066    /// busy bound instead of waited out.
1067    #[test]
1068    fn stored_counts_gives_up_on_a_held_graph_within_its_bound() {
1069        let dir = tempfile::tempdir().unwrap();
1070        let db = dir.path().join("graph.db");
1071        {
1072            let conn = Connection::open(&db).unwrap();
1073            schema::initialize(&conn).unwrap();
1074        }
1075        assert_eq!(
1076            stored_counts_at(&db, STORED_COUNTS_BUSY),
1077            Some(StoredCounts { files: 0, nodes: 0 })
1078        );
1079
1080        let holder = Connection::open(&db).unwrap();
1081        holder
1082            .execute_batch(
1083                "PRAGMA locking_mode = EXCLUSIVE;
1084                 BEGIN EXCLUSIVE;
1085                 INSERT INTO paths(path) VALUES ('held');",
1086            )
1087            .unwrap();
1088
1089        let started = std::time::Instant::now();
1090        let busy = std::time::Duration::from_millis(100);
1091        assert_eq!(stored_counts_at(&db, busy), None, "a held graph is busy");
1092        assert!(
1093            started.elapsed() < std::time::Duration::from_secs(5),
1094            "waited {:?} on a held graph",
1095            started.elapsed()
1096        );
1097        holder.execute_batch("ROLLBACK;").unwrap();
1098    }
1099
1100    /// #2179: reading a graph of an older schema reports nothing and leaves
1101    /// it as it is; `CodeGraph::open` would drop its tables.
1102    #[test]
1103    fn stored_counts_leaves_a_legacy_schema_untouched() {
1104        let dir = tempfile::tempdir().unwrap();
1105        let db = dir.path().join("graph.db");
1106        {
1107            let conn = Connection::open(&db).unwrap();
1108            conn.execute_batch(
1109                "CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_path TEXT);
1110                 INSERT INTO nodes(file_path) VALUES ('src/a.rs');",
1111            )
1112            .unwrap();
1113        }
1114        assert_eq!(stored_counts_at(&db, STORED_COUNTS_BUSY), None);
1115        let conn = Connection::open(&db).unwrap();
1116        let kept: i64 = conn
1117            .query_row("SELECT count(*) FROM nodes", [], |r| r.get(0))
1118            .unwrap();
1119        assert_eq!(kept, 1, "the legacy table must survive a status read");
1120    }
1121}