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