1mod 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
30pub 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
45fn 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
65pub const GRAPH_ENGINE_VERSION: u32 = 4;
80
81pub fn engine_outdated(project_root: &str) -> bool {
87 load_meta(project_root).is_none_or(|m| m.engine_version < GRAPH_ENGINE_VERSION)
88}
89
90fn 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 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 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 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 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 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 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 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 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 pub fn file_node_count(&self) -> anyhow::Result<usize> {
327 node::file_count(&self.conn)
328 }
329
330 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)] 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 assert!(engine_outdated(root), "missing meta must read as outdated");
684
685 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.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}