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