mod edge;
mod meta;
mod node;
mod queries;
mod schema;
pub use edge::{Edge, EdgeKind};
pub use meta::{load_meta, meta_path, write_meta, PropertyGraphMetaV1};
pub use node::{Node, NodeKind};
pub use queries::{
edge_weight, file_connectivity, related_files, DependencyChain, GraphQuery, ImpactResult,
};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
pub fn graph_dir(project_root: &str) -> PathBuf {
if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
let normalized = crate::core::graph_index::normalize_project_root(project_root);
let hash = crate::core::project_hash::hash_project_root(&normalized);
data_dir.join("graphs").join(hash)
} else {
Path::new(project_root).join(".lean-ctx")
}
}
fn migrate_if_needed(project_root: &str, new_dir: &Path) {
let old_dir = Path::new(project_root).join(".lean-ctx");
if old_dir == new_dir {
return;
}
for file in &["graph.db", "graph.meta.json"] {
let old = old_dir.join(file);
let new = new_dir.join(file);
if old.exists()
&& !new.exists()
&& std::fs::rename(&old, &new).is_err()
&& std::fs::copy(&old, &new).is_ok()
{
let _ = std::fs::remove_file(&old);
}
}
}
pub struct CodeGraph {
conn: Connection,
db_path: PathBuf,
}
impl CodeGraph {
pub fn open(project_root: &str) -> anyhow::Result<Self> {
let db_dir = graph_dir(project_root);
std::fs::create_dir_all(&db_dir)?;
migrate_if_needed(project_root, &db_dir);
let db_path = db_dir.join("graph.db");
let conn = Connection::open(&db_path)?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
schema::initialize(&conn)?;
Ok(Self { conn, db_path })
}
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
schema::initialize(&conn)?;
Ok(Self {
conn,
db_path: PathBuf::from(":memory:"),
})
}
pub fn db_path(&self) -> &Path {
&self.db_path
}
pub fn connection(&self) -> &Connection {
&self.conn
}
pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
node::upsert(&self.conn, node)
}
pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
edge::upsert(&self.conn, edge)
}
pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
node::get_by_path(&self.conn, file_path)
}
pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
node::get_by_symbol(&self.conn, name, file_path)
}
pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
node::remove_by_file(&self.conn, file_path)
}
pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
edge::from_node(&self.conn, node_id)
}
pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
edge::to_node(&self.conn, node_id)
}
pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
queries::dependents(&self.conn, file_path)
}
pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
queries::dependencies(&self.conn, file_path)
}
pub fn impact_analysis(
&self,
file_path: &str,
max_depth: usize,
) -> anyhow::Result<ImpactResult> {
queries::impact_analysis(&self.conn, file_path, max_depth)
}
pub fn dependency_chain(
&self,
from: &str,
to: &str,
) -> anyhow::Result<Option<DependencyChain>> {
queries::dependency_chain(&self.conn, from, to)
}
pub fn related_files(
&self,
file_path: &str,
limit: usize,
) -> anyhow::Result<Vec<(String, f64)>> {
queries::related_files(&self.conn, file_path, limit)
}
pub fn file_connectivity(
&self,
file_path: &str,
) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
queries::file_connectivity(&self.conn, file_path)
}
pub fn node_count(&self) -> anyhow::Result<usize> {
node::count(&self.conn)
}
pub fn edge_count(&self) -> anyhow::Result<usize> {
edge::count(&self.conn)
}
pub fn clear(&self) -> anyhow::Result<()> {
self.conn
.execute_batch("DELETE FROM edges; DELETE FROM nodes;")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_graph() -> CodeGraph {
CodeGraph::open_in_memory().unwrap()
}
#[test]
fn create_and_query_nodes() {
let g = test_graph();
let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
assert!(id > 0);
let found = g.get_node_by_path("src/main.rs").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().file_path, "src/main.rs");
}
#[test]
fn create_and_query_edges() {
let g = test_graph();
let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
let from_a = g.edges_from(a).unwrap();
assert_eq!(from_a.len(), 1);
assert_eq!(from_a[0].target_id, b);
let to_b = g.edges_to(b).unwrap();
assert_eq!(to_b.len(), 1);
assert_eq!(to_b[0].source_id, a);
}
#[test]
fn dependents_query() {
let g = test_graph();
let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
.unwrap();
g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
.unwrap();
let deps = g.dependents("src/lib.rs").unwrap();
assert_eq!(deps.len(), 2);
assert!(deps.contains(&"src/main.rs".to_string()));
assert!(deps.contains(&"src/utils.rs".to_string()));
}
#[test]
fn dependencies_query() {
let g = test_graph();
let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
.unwrap();
g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
.unwrap();
let deps = g.dependencies("src/main.rs").unwrap();
assert_eq!(deps.len(), 2);
}
#[test]
#[allow(clippy::many_single_char_names)] fn impact_analysis_depth() {
let g = test_graph();
let a = g.upsert_node(&Node::file("a.rs")).unwrap();
let b = g.upsert_node(&Node::file("b.rs")).unwrap();
let c = g.upsert_node(&Node::file("c.rs")).unwrap();
let d = g.upsert_node(&Node::file("d.rs")).unwrap();
g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
let impact = g.impact_analysis("a.rs", 2).unwrap();
assert!(impact.affected_files.contains(&"b.rs".to_string()));
assert!(impact.affected_files.contains(&"c.rs".to_string()));
assert!(!impact.affected_files.contains(&"d.rs".to_string()));
let deep = g.impact_analysis("a.rs", 10).unwrap();
assert!(deep.affected_files.contains(&"d.rs".to_string()));
}
#[test]
fn upsert_idempotent() {
let g = test_graph();
let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
assert_eq!(id1, id2);
assert_eq!(g.node_count().unwrap(), 1);
}
#[test]
fn remove_file_cascades() {
let g = test_graph();
let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
let sym = g
.upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
.unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
g.remove_file_nodes("src/a.rs").unwrap();
assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
assert_eq!(g.edge_count().unwrap(), 0);
}
#[test]
fn dependency_chain_found() {
let g = test_graph();
let a = g.upsert_node(&Node::file("a.rs")).unwrap();
let b = g.upsert_node(&Node::file("b.rs")).unwrap();
let c = g.upsert_node(&Node::file("c.rs")).unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
assert!(chain.is_some());
let chain = chain.unwrap();
assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
}
#[test]
fn counts() {
let g = test_graph();
assert_eq!(g.node_count().unwrap(), 0);
assert_eq!(g.edge_count().unwrap(), 0);
let a = g.upsert_node(&Node::file("a.rs")).unwrap();
let b = g.upsert_node(&Node::file("b.rs")).unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
assert_eq!(g.node_count().unwrap(), 2);
assert_eq!(g.edge_count().unwrap(), 1);
}
#[test]
fn multi_edge_dependents() {
let g = test_graph();
let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
let deps = g.dependents("src/a.rs").unwrap();
assert_eq!(deps.len(), 2);
assert!(deps.contains(&"src/b.rs".to_string()));
assert!(deps.contains(&"src/c.rs".to_string()));
}
#[test]
fn multi_edge_impact_analysis() {
let g = test_graph();
let a = g.upsert_node(&Node::file("a.rs")).unwrap();
let b = g.upsert_node(&Node::file("b.rs")).unwrap();
let c = g.upsert_node(&Node::file("c.rs")).unwrap();
g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
let impact = g.impact_analysis("a.rs", 10).unwrap();
assert!(impact.affected_files.contains(&"b.rs".to_string()));
assert!(impact.affected_files.contains(&"c.rs".to_string()));
}
#[test]
fn related_files_scored() {
let g = test_graph();
let a = g.upsert_node(&Node::file("a.rs")).unwrap();
let b = g.upsert_node(&Node::file("b.rs")).unwrap();
let c = g.upsert_node(&Node::file("c.rs")).unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
let related = g.related_files("a.rs", 10).unwrap();
assert_eq!(related.len(), 2);
let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
assert!(
b_score > c_score,
"b.rs has imports+calls, should rank higher than c.rs with type_ref"
);
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn graph_dir_uses_data_dir_when_set() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
std::fs::create_dir_all(&project).unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let _guard = env_lock();
std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
let dir = graph_dir(project.to_str().unwrap());
assert!(dir.starts_with(&data_dir));
assert!(dir.to_string_lossy().contains("graphs"));
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn graph_dir_returns_consistent_hash_dir() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("hash_project");
std::fs::create_dir_all(&project).unwrap();
let data_dir = tmp.path().join("data2");
std::fs::create_dir_all(&data_dir).unwrap();
let _guard = env_lock();
std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
let dir1 = graph_dir(project.to_str().unwrap());
let dir2 = graph_dir(project.to_str().unwrap());
assert_eq!(dir1, dir2, "graph_dir should be deterministic");
assert!(dir1.to_string_lossy().contains("graphs"));
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn migration_moves_old_files() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("migtest");
let old_dir = project.join(".lean-ctx");
std::fs::create_dir_all(&old_dir).unwrap();
std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
let new_dir = tmp.path().join("newloc");
std::fs::create_dir_all(&new_dir).unwrap();
migrate_if_needed(project.to_str().unwrap(), &new_dir);
assert!(new_dir.join("graph.db").exists());
assert!(new_dir.join("graph.meta.json").exists());
assert!(!old_dir.join("graph.db").exists());
assert!(!old_dir.join("graph.meta.json").exists());
assert_eq!(
std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
"old-db-content"
);
}
#[test]
fn migration_skips_when_new_exists() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("skiptest");
let old_dir = project.join(".lean-ctx");
std::fs::create_dir_all(&old_dir).unwrap();
std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
let new_dir = tmp.path().join("newloc2");
std::fs::create_dir_all(&new_dir).unwrap();
std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
migrate_if_needed(project.to_str().unwrap(), &new_dir);
assert_eq!(
std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
"already-there"
);
assert!(old_dir.join("graph.db").exists());
}
#[test]
fn open_with_data_dir() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("opentest");
std::fs::create_dir_all(&project).unwrap();
let data_dir = tmp.path().join("xdata");
std::fs::create_dir_all(&data_dir).unwrap();
let _guard = env_lock();
std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
assert!(g.db_path().starts_with(&data_dir));
assert!(g.db_path().to_string_lossy().contains("graph.db"));
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn meta_path_uses_graph_dir() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("metatest");
std::fs::create_dir_all(&project).unwrap();
let data_dir = tmp.path().join("mdata");
std::fs::create_dir_all(&data_dir).unwrap();
let _guard = env_lock();
std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
let mp = meta::meta_path(project.to_str().unwrap());
assert!(mp.starts_with(&data_dir));
assert!(mp.to_string_lossy().contains("graph.meta.json"));
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
}