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 symbol_count(&self) -> anyhow::Result<usize> {
257 node::symbol_count(&self.conn)
258 }
259
260 pub fn all_symbols(&self) -> anyhow::Result<Vec<Node>> {
263 node::all_symbols(&self.conn)
264 }
265
266 pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
267 node::all_edges_flat(&self.conn)
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::core::data_dir::test_env_lock;
275
276 fn test_graph() -> CodeGraph {
277 CodeGraph::open_in_memory().unwrap()
278 }
279
280 #[test]
281 fn create_and_query_nodes() {
282 let g = test_graph();
283
284 let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
285 assert!(id > 0);
286
287 let found = g.get_node_by_path("src/main.rs").unwrap();
288 assert!(found.is_some());
289 assert_eq!(found.unwrap().file_path, "src/main.rs");
290 }
291
292 #[test]
293 fn create_and_query_edges() {
294 let g = test_graph();
295
296 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
297 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
298
299 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
300
301 let from_a = g.edges_from(a).unwrap();
302 assert_eq!(from_a.len(), 1);
303 assert_eq!(from_a[0].target_id, b);
304
305 let to_b = g.edges_to(b).unwrap();
306 assert_eq!(to_b.len(), 1);
307 assert_eq!(to_b[0].source_id, a);
308 }
309
310 #[test]
311 fn dependents_query() {
312 let g = test_graph();
313
314 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
315 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
316 let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
317
318 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
319 .unwrap();
320 g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
321 .unwrap();
322
323 let deps = g.dependents("src/lib.rs").unwrap();
324 assert_eq!(deps.len(), 2);
325 assert!(deps.contains(&"src/main.rs".to_string()));
326 assert!(deps.contains(&"src/utils.rs".to_string()));
327 }
328
329 #[test]
330 fn dependencies_query() {
331 let g = test_graph();
332
333 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
334 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
335 let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
336
337 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
338 .unwrap();
339 g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
340 .unwrap();
341
342 let deps = g.dependencies("src/main.rs").unwrap();
343 assert_eq!(deps.len(), 2);
344 }
345
346 #[test]
347 #[allow(clippy::many_single_char_names)] fn impact_analysis_depth() {
349 let g = test_graph();
350
351 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
352 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
353 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
354 let d = g.upsert_node(&Node::file("d.rs")).unwrap();
355
356 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
357 g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
358 g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
359
360 let impact = g.impact_analysis("a.rs", 2).unwrap();
361 assert!(impact.affected_files.contains(&"b.rs".to_string()));
362 assert!(impact.affected_files.contains(&"c.rs".to_string()));
363 assert!(!impact.affected_files.contains(&"d.rs".to_string()));
364
365 let deep = g.impact_analysis("a.rs", 10).unwrap();
366 assert!(deep.affected_files.contains(&"d.rs".to_string()));
367 }
368
369 #[test]
370 fn upsert_idempotent() {
371 let g = test_graph();
372
373 let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
374 let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
375 assert_eq!(id1, id2);
376 assert_eq!(g.node_count().unwrap(), 1);
377 }
378
379 #[test]
380 fn remove_file_cascades() {
381 let g = test_graph();
382
383 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
384 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
385 let sym = g
386 .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
387 .unwrap();
388
389 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
390 g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
391
392 g.remove_file_nodes("src/a.rs").unwrap();
393
394 assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
395 assert_eq!(g.edge_count().unwrap(), 0);
396 }
397
398 #[test]
399 fn dependency_chain_found() {
400 let g = test_graph();
401
402 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
403 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
404 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
405
406 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
407 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
408
409 let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
410 assert!(chain.is_some());
411 let chain = chain.unwrap();
412 assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
413 }
414
415 #[test]
416 fn counts() {
417 let g = test_graph();
418 assert_eq!(g.node_count().unwrap(), 0);
419 assert_eq!(g.edge_count().unwrap(), 0);
420
421 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
422 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
423 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
424
425 assert_eq!(g.node_count().unwrap(), 2);
426 assert_eq!(g.edge_count().unwrap(), 1);
427 }
428
429 #[test]
430 fn multi_edge_dependents() {
431 let g = test_graph();
432
433 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
434 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
435 let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
436
437 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
438 g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
439
440 let deps = g.dependents("src/a.rs").unwrap();
441 assert_eq!(deps.len(), 2);
442 assert!(deps.contains(&"src/b.rs".to_string()));
443 assert!(deps.contains(&"src/c.rs".to_string()));
444 }
445
446 #[test]
447 fn multi_edge_impact_analysis() {
448 let g = test_graph();
449
450 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
451 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
452 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
453
454 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
455 g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
456
457 let impact = g.impact_analysis("a.rs", 10).unwrap();
458 assert!(impact.affected_files.contains(&"b.rs".to_string()));
459 assert!(impact.affected_files.contains(&"c.rs".to_string()));
460 }
461
462 #[test]
463 fn related_files_scored() {
464 let g = test_graph();
465
466 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
467 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
468 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
469
470 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
471 g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
472 g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
473
474 let related = g.related_files("a.rs", 10).unwrap();
475 assert_eq!(related.len(), 2);
476 let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
477 let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
478 assert!(
479 b_score > c_score,
480 "b.rs has imports+calls, should rank higher than c.rs with type_ref"
481 );
482 }
483
484 #[test]
485 fn graph_dir_uses_data_dir_when_set() {
486 let tmp = tempfile::tempdir().unwrap();
487 let project = tmp.path().join("myproject");
488 std::fs::create_dir_all(&project).unwrap();
489
490 let data_dir = tmp.path().join("data");
491 std::fs::create_dir_all(&data_dir).unwrap();
492
493 let _guard = test_env_lock();
494 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
495
496 let dir = graph_dir(project.to_str().unwrap());
497 assert!(dir.starts_with(&data_dir));
498 assert!(dir.to_string_lossy().contains("graphs"));
499
500 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
501 }
502
503 #[test]
504 fn graph_dir_returns_consistent_hash_dir() {
505 let tmp = tempfile::tempdir().unwrap();
506 let project = tmp.path().join("hash_project");
507 std::fs::create_dir_all(&project).unwrap();
508
509 let data_dir = tmp.path().join("data2");
510 std::fs::create_dir_all(&data_dir).unwrap();
511
512 let _guard = test_env_lock();
513 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
514
515 let dir1 = graph_dir(project.to_str().unwrap());
516 let dir2 = graph_dir(project.to_str().unwrap());
517 assert_eq!(dir1, dir2, "graph_dir should be deterministic");
518 assert!(dir1.to_string_lossy().contains("graphs"));
519
520 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
521 }
522
523 #[test]
524 fn migration_moves_old_files() {
525 let tmp = tempfile::tempdir().unwrap();
526 let project = tmp.path().join("migtest");
527 let old_dir = project.join(".lean-ctx");
528 std::fs::create_dir_all(&old_dir).unwrap();
529 std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
530 std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
531
532 let new_dir = tmp.path().join("newloc");
533 std::fs::create_dir_all(&new_dir).unwrap();
534
535 migrate_if_needed(project.to_str().unwrap(), &new_dir);
536
537 assert!(new_dir.join("graph.db").exists());
538 assert!(new_dir.join("graph.meta.json").exists());
539 assert!(!old_dir.join("graph.db").exists());
540 assert!(!old_dir.join("graph.meta.json").exists());
541 assert_eq!(
542 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
543 "old-db-content"
544 );
545 }
546
547 #[test]
548 fn migration_skips_when_new_exists() {
549 let tmp = tempfile::tempdir().unwrap();
550 let project = tmp.path().join("skiptest");
551 let old_dir = project.join(".lean-ctx");
552 std::fs::create_dir_all(&old_dir).unwrap();
553 std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
554
555 let new_dir = tmp.path().join("newloc2");
556 std::fs::create_dir_all(&new_dir).unwrap();
557 std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
558
559 migrate_if_needed(project.to_str().unwrap(), &new_dir);
560
561 assert_eq!(
562 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
563 "already-there"
564 );
565 assert!(old_dir.join("graph.db").exists());
566 }
567
568 #[test]
569 fn open_with_data_dir() {
570 let tmp = tempfile::tempdir().unwrap();
571 let project = tmp.path().join("opentest");
572 std::fs::create_dir_all(&project).unwrap();
573
574 let data_dir = tmp.path().join("xdata");
575 std::fs::create_dir_all(&data_dir).unwrap();
576
577 let _guard = test_env_lock();
578 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
579
580 let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
581 assert!(g.db_path().starts_with(&data_dir));
582 assert!(g.db_path().to_string_lossy().contains("graph.db"));
583
584 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
585 }
586
587 #[test]
588 fn meta_path_uses_graph_dir() {
589 let tmp = tempfile::tempdir().unwrap();
590 let project = tmp.path().join("metatest");
591 std::fs::create_dir_all(&project).unwrap();
592
593 let data_dir = tmp.path().join("mdata");
594 std::fs::create_dir_all(&data_dir).unwrap();
595
596 let _guard = test_env_lock();
597 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
598
599 let mp = meta::meta_path(project.to_str().unwrap());
600 assert!(mp.starts_with(&data_dir));
601 assert!(mp.to_string_lossy().contains("graph.meta.json"));
602
603 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
604 }
605
606 #[test]
607 fn engine_outdated_flags_old_and_missing_meta() {
608 let _iso = crate::core::data_dir::isolated_data_dir();
609 let proj = tempfile::tempdir().unwrap();
610 let root = proj.path().to_str().unwrap();
611
612 assert!(engine_outdated(root), "missing meta must read as outdated");
614
615 let mut meta = PropertyGraphMetaV1 {
617 built_at: "2026-01-01T00:00:00Z".to_string(),
618 engine_version: 0,
619 ..Default::default()
620 };
621 write_meta(root, &meta).unwrap();
622 assert!(
623 engine_outdated(root),
624 "engine_version 0 must read as outdated"
625 );
626
627 meta.engine_version = GRAPH_ENGINE_VERSION;
629 write_meta(root, &meta).unwrap();
630 assert!(
631 !engine_outdated(root),
632 "current engine_version must read as up to date"
633 );
634 }
635}