1mod edge;
9pub mod file_catalog;
10mod meta;
11mod node;
12mod queries;
13mod schema;
14pub mod snapshot;
15
16pub use edge::{Edge, EdgeKind};
17pub use file_catalog::FileCatalogEntry;
18pub use meta::{PropertyGraphMetaV1, load_meta, meta_path, write_meta};
19pub use node::{Node, NodeKind};
20pub use queries::{
21 DependencyChain, GraphQuery, ImpactResult, edge_weight, file_connectivity, related_files,
22};
23
24use rusqlite::Connection;
25use std::path::{Path, PathBuf};
26
27pub fn graph_dir(project_root: &str) -> PathBuf {
33 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
34 let normalized = crate::core::graph_index::normalize_project_root(project_root);
35 let hash = crate::core::project_hash::hash_project_root(&normalized);
36 data_dir.join("graphs").join(hash)
37 } else {
38 Path::new(project_root).join(".lean-ctx")
39 }
40}
41
42fn migrate_if_needed(project_root: &str, new_dir: &Path) {
45 let old_dir = Path::new(project_root).join(".lean-ctx");
46 if old_dir == new_dir {
47 return;
48 }
49 for file in &["graph.db", "graph.meta.json"] {
50 let old = old_dir.join(file);
51 let new = new_dir.join(file);
52 if old.exists()
53 && !new.exists()
54 && std::fs::rename(&old, &new).is_err()
55 && std::fs::copy(&old, &new).is_ok()
56 {
57 let _ = std::fs::remove_file(&old);
58 }
59 }
60}
61
62pub const GRAPH_ENGINE_VERSION: u32 = 2;
69
70pub fn engine_outdated(project_root: &str) -> bool {
76 load_meta(project_root).is_none_or(|m| m.engine_version < GRAPH_ENGINE_VERSION)
77}
78
79pub struct CodeGraph {
80 conn: Connection,
81 db_path: PathBuf,
82}
83
84impl CodeGraph {
85 pub fn open(project_root: &str) -> anyhow::Result<Self> {
86 let db_dir = graph_dir(project_root);
87 std::fs::create_dir_all(&db_dir)?;
88 migrate_if_needed(project_root, &db_dir);
89 let db_path = db_dir.join("graph.db");
90 let conn = Connection::open(&db_path)?;
91 conn.busy_timeout(std::time::Duration::from_secs(5))?;
92 schema::initialize(&conn)?;
93 Ok(Self { conn, db_path })
94 }
95
96 pub fn open_in_memory() -> anyhow::Result<Self> {
97 let conn = Connection::open_in_memory()?;
98 schema::initialize(&conn)?;
99 Ok(Self {
100 conn,
101 db_path: PathBuf::from(":memory:"),
102 })
103 }
104
105 pub fn db_path(&self) -> &Path {
106 &self.db_path
107 }
108
109 pub fn connection(&self) -> &Connection {
110 &self.conn
111 }
112
113 pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
114 node::upsert(&self.conn, node)
115 }
116
117 pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
118 edge::upsert(&self.conn, edge)
119 }
120
121 pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
122 node::get_by_path(&self.conn, file_path)
123 }
124
125 pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
126 node::get_by_symbol(&self.conn, name, file_path)
127 }
128
129 pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
130 node::remove_by_file(&self.conn, file_path)
131 }
132
133 pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
134 edge::from_node(&self.conn, node_id)
135 }
136
137 pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
138 edge::to_node(&self.conn, node_id)
139 }
140
141 pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
142 queries::dependents(&self.conn, file_path)
143 }
144
145 pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
146 queries::dependencies(&self.conn, file_path)
147 }
148
149 pub fn impact_analysis(
150 &self,
151 file_path: &str,
152 max_depth: usize,
153 ) -> anyhow::Result<ImpactResult> {
154 queries::impact_analysis(&self.conn, file_path, max_depth)
155 }
156
157 pub fn dependency_chain(
158 &self,
159 from: &str,
160 to: &str,
161 ) -> anyhow::Result<Option<DependencyChain>> {
162 queries::dependency_chain(&self.conn, from, to)
163 }
164
165 pub fn related_files(
166 &self,
167 file_path: &str,
168 limit: usize,
169 ) -> anyhow::Result<Vec<(String, f64)>> {
170 queries::related_files(&self.conn, file_path, limit)
171 }
172
173 pub fn file_connectivity(
174 &self,
175 file_path: &str,
176 ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
177 queries::file_connectivity(&self.conn, file_path)
178 }
179
180 pub fn node_count(&self) -> anyhow::Result<usize> {
181 node::count(&self.conn)
182 }
183
184 pub fn edge_count(&self) -> anyhow::Result<usize> {
185 edge::count(&self.conn)
186 }
187
188 pub fn clear(&self) -> anyhow::Result<()> {
189 self.conn
190 .execute_batch("DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog;")?;
191 Ok(())
192 }
193
194 pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
195 file_catalog::upsert(&self.conn, entry)
196 }
197
198 pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
199 file_catalog::get(&self.conn, path)
200 }
201
202 pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
203 file_catalog::count(&self.conn)
204 }
205
206 pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
207 file_catalog::all_paths(&self.conn)
208 }
209
210 pub fn find_symbols(
211 &self,
212 name: &str,
213 file_filter: Option<&str>,
214 kind_filter: Option<&str>,
215 ) -> anyhow::Result<Vec<Node>> {
216 node::find_symbols(&self.conn, name, file_filter, kind_filter)
217 }
218
219 pub fn symbol_count(&self) -> anyhow::Result<usize> {
220 node::symbol_count(&self.conn)
221 }
222
223 pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
224 node::all_edges_flat(&self.conn)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::core::data_dir::test_env_lock;
232
233 fn test_graph() -> CodeGraph {
234 CodeGraph::open_in_memory().unwrap()
235 }
236
237 #[test]
238 fn create_and_query_nodes() {
239 let g = test_graph();
240
241 let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
242 assert!(id > 0);
243
244 let found = g.get_node_by_path("src/main.rs").unwrap();
245 assert!(found.is_some());
246 assert_eq!(found.unwrap().file_path, "src/main.rs");
247 }
248
249 #[test]
250 fn create_and_query_edges() {
251 let g = test_graph();
252
253 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
254 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
255
256 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
257
258 let from_a = g.edges_from(a).unwrap();
259 assert_eq!(from_a.len(), 1);
260 assert_eq!(from_a[0].target_id, b);
261
262 let to_b = g.edges_to(b).unwrap();
263 assert_eq!(to_b.len(), 1);
264 assert_eq!(to_b[0].source_id, a);
265 }
266
267 #[test]
268 fn dependents_query() {
269 let g = test_graph();
270
271 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
272 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
273 let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
274
275 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
276 .unwrap();
277 g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
278 .unwrap();
279
280 let deps = g.dependents("src/lib.rs").unwrap();
281 assert_eq!(deps.len(), 2);
282 assert!(deps.contains(&"src/main.rs".to_string()));
283 assert!(deps.contains(&"src/utils.rs".to_string()));
284 }
285
286 #[test]
287 fn dependencies_query() {
288 let g = test_graph();
289
290 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
291 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
292 let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
293
294 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
295 .unwrap();
296 g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
297 .unwrap();
298
299 let deps = g.dependencies("src/main.rs").unwrap();
300 assert_eq!(deps.len(), 2);
301 }
302
303 #[test]
304 #[allow(clippy::many_single_char_names)] fn impact_analysis_depth() {
306 let g = test_graph();
307
308 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
309 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
310 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
311 let d = g.upsert_node(&Node::file("d.rs")).unwrap();
312
313 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
314 g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
315 g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
316
317 let impact = g.impact_analysis("a.rs", 2).unwrap();
318 assert!(impact.affected_files.contains(&"b.rs".to_string()));
319 assert!(impact.affected_files.contains(&"c.rs".to_string()));
320 assert!(!impact.affected_files.contains(&"d.rs".to_string()));
321
322 let deep = g.impact_analysis("a.rs", 10).unwrap();
323 assert!(deep.affected_files.contains(&"d.rs".to_string()));
324 }
325
326 #[test]
327 fn upsert_idempotent() {
328 let g = test_graph();
329
330 let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
331 let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
332 assert_eq!(id1, id2);
333 assert_eq!(g.node_count().unwrap(), 1);
334 }
335
336 #[test]
337 fn remove_file_cascades() {
338 let g = test_graph();
339
340 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
341 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
342 let sym = g
343 .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
344 .unwrap();
345
346 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
347 g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
348
349 g.remove_file_nodes("src/a.rs").unwrap();
350
351 assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
352 assert_eq!(g.edge_count().unwrap(), 0);
353 }
354
355 #[test]
356 fn dependency_chain_found() {
357 let g = test_graph();
358
359 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
360 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
361 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
362
363 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
364 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
365
366 let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
367 assert!(chain.is_some());
368 let chain = chain.unwrap();
369 assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
370 }
371
372 #[test]
373 fn counts() {
374 let g = test_graph();
375 assert_eq!(g.node_count().unwrap(), 0);
376 assert_eq!(g.edge_count().unwrap(), 0);
377
378 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
379 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
380 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
381
382 assert_eq!(g.node_count().unwrap(), 2);
383 assert_eq!(g.edge_count().unwrap(), 1);
384 }
385
386 #[test]
387 fn multi_edge_dependents() {
388 let g = test_graph();
389
390 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
391 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
392 let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
393
394 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
395 g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
396
397 let deps = g.dependents("src/a.rs").unwrap();
398 assert_eq!(deps.len(), 2);
399 assert!(deps.contains(&"src/b.rs".to_string()));
400 assert!(deps.contains(&"src/c.rs".to_string()));
401 }
402
403 #[test]
404 fn multi_edge_impact_analysis() {
405 let g = test_graph();
406
407 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
408 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
409 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
410
411 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
412 g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
413
414 let impact = g.impact_analysis("a.rs", 10).unwrap();
415 assert!(impact.affected_files.contains(&"b.rs".to_string()));
416 assert!(impact.affected_files.contains(&"c.rs".to_string()));
417 }
418
419 #[test]
420 fn related_files_scored() {
421 let g = test_graph();
422
423 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
424 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
425 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
426
427 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
428 g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
429 g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
430
431 let related = g.related_files("a.rs", 10).unwrap();
432 assert_eq!(related.len(), 2);
433 let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
434 let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
435 assert!(
436 b_score > c_score,
437 "b.rs has imports+calls, should rank higher than c.rs with type_ref"
438 );
439 }
440
441 #[test]
442 fn graph_dir_uses_data_dir_when_set() {
443 let tmp = tempfile::tempdir().unwrap();
444 let project = tmp.path().join("myproject");
445 std::fs::create_dir_all(&project).unwrap();
446
447 let data_dir = tmp.path().join("data");
448 std::fs::create_dir_all(&data_dir).unwrap();
449
450 let _guard = test_env_lock();
451 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
452
453 let dir = graph_dir(project.to_str().unwrap());
454 assert!(dir.starts_with(&data_dir));
455 assert!(dir.to_string_lossy().contains("graphs"));
456
457 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
458 }
459
460 #[test]
461 fn graph_dir_returns_consistent_hash_dir() {
462 let tmp = tempfile::tempdir().unwrap();
463 let project = tmp.path().join("hash_project");
464 std::fs::create_dir_all(&project).unwrap();
465
466 let data_dir = tmp.path().join("data2");
467 std::fs::create_dir_all(&data_dir).unwrap();
468
469 let _guard = test_env_lock();
470 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
471
472 let dir1 = graph_dir(project.to_str().unwrap());
473 let dir2 = graph_dir(project.to_str().unwrap());
474 assert_eq!(dir1, dir2, "graph_dir should be deterministic");
475 assert!(dir1.to_string_lossy().contains("graphs"));
476
477 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
478 }
479
480 #[test]
481 fn migration_moves_old_files() {
482 let tmp = tempfile::tempdir().unwrap();
483 let project = tmp.path().join("migtest");
484 let old_dir = project.join(".lean-ctx");
485 std::fs::create_dir_all(&old_dir).unwrap();
486 std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
487 std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
488
489 let new_dir = tmp.path().join("newloc");
490 std::fs::create_dir_all(&new_dir).unwrap();
491
492 migrate_if_needed(project.to_str().unwrap(), &new_dir);
493
494 assert!(new_dir.join("graph.db").exists());
495 assert!(new_dir.join("graph.meta.json").exists());
496 assert!(!old_dir.join("graph.db").exists());
497 assert!(!old_dir.join("graph.meta.json").exists());
498 assert_eq!(
499 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
500 "old-db-content"
501 );
502 }
503
504 #[test]
505 fn migration_skips_when_new_exists() {
506 let tmp = tempfile::tempdir().unwrap();
507 let project = tmp.path().join("skiptest");
508 let old_dir = project.join(".lean-ctx");
509 std::fs::create_dir_all(&old_dir).unwrap();
510 std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
511
512 let new_dir = tmp.path().join("newloc2");
513 std::fs::create_dir_all(&new_dir).unwrap();
514 std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
515
516 migrate_if_needed(project.to_str().unwrap(), &new_dir);
517
518 assert_eq!(
519 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
520 "already-there"
521 );
522 assert!(old_dir.join("graph.db").exists());
523 }
524
525 #[test]
526 fn open_with_data_dir() {
527 let tmp = tempfile::tempdir().unwrap();
528 let project = tmp.path().join("opentest");
529 std::fs::create_dir_all(&project).unwrap();
530
531 let data_dir = tmp.path().join("xdata");
532 std::fs::create_dir_all(&data_dir).unwrap();
533
534 let _guard = test_env_lock();
535 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
536
537 let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
538 assert!(g.db_path().starts_with(&data_dir));
539 assert!(g.db_path().to_string_lossy().contains("graph.db"));
540
541 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
542 }
543
544 #[test]
545 fn meta_path_uses_graph_dir() {
546 let tmp = tempfile::tempdir().unwrap();
547 let project = tmp.path().join("metatest");
548 std::fs::create_dir_all(&project).unwrap();
549
550 let data_dir = tmp.path().join("mdata");
551 std::fs::create_dir_all(&data_dir).unwrap();
552
553 let _guard = test_env_lock();
554 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
555
556 let mp = meta::meta_path(project.to_str().unwrap());
557 assert!(mp.starts_with(&data_dir));
558 assert!(mp.to_string_lossy().contains("graph.meta.json"));
559
560 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
561 }
562
563 #[test]
564 fn engine_outdated_flags_old_and_missing_meta() {
565 let _iso = crate::core::data_dir::isolated_data_dir();
566 let proj = tempfile::tempdir().unwrap();
567 let root = proj.path().to_str().unwrap();
568
569 assert!(engine_outdated(root), "missing meta must read as outdated");
571
572 let mut meta = PropertyGraphMetaV1 {
574 built_at: "2026-01-01T00:00:00Z".to_string(),
575 engine_version: 0,
576 ..Default::default()
577 };
578 write_meta(root, &meta).unwrap();
579 assert!(
580 engine_outdated(root),
581 "engine_version 0 must read as outdated"
582 );
583
584 meta.engine_version = GRAPH_ENGINE_VERSION;
586 write_meta(root, &meta).unwrap();
587 assert!(
588 !engine_outdated(root),
589 "current engine_version must read as up to date"
590 );
591 }
592}