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