1mod cross_source;
9mod edge;
10pub mod file_catalog;
11mod meta;
12pub mod node;
13mod path_id;
14mod queries;
15mod schema;
16pub mod semantic_cache;
17pub(crate) mod served;
18pub mod snapshot;
19mod sync;
20
21pub use edge::{Edge, EdgeKind};
22pub use file_catalog::FileCatalogEntry;
23pub use meta::{PropertyGraphMetaV1, load_meta, meta_path, write_meta};
24pub use node::{Node, NodeKind};
25pub use queries::{
26 DependencyChain, GraphQuery, ImpactResult, Served, edge_weight, file_connectivity,
27 related_files,
28};
29pub use semantic_cache::CachedResolution;
30pub use served::GraphSource;
31pub use sync::{mirror_index, parse_symbol_metadata, populate_from_project_index};
32
33use rusqlite::Connection;
34use std::path::{Path, PathBuf};
35
36pub fn graph_dir(project_root: &str) -> PathBuf {
42 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
43 let normalized = crate::core::graph_index::normalize_project_root(project_root);
44 let hash = crate::core::project_hash::hash_project_root(&normalized);
45 data_dir.join("graphs").join(hash)
46 } else {
47 Path::new(project_root).join(".lean-ctx")
48 }
49}
50
51fn migrate_if_needed(project_root: &str, new_dir: &Path) {
54 let old_dir = Path::new(project_root).join(".lean-ctx");
55 if old_dir == new_dir {
56 return;
57 }
58 for file in &["graph.db", "graph.meta.json"] {
59 let old = old_dir.join(file);
60 let new = new_dir.join(file);
61 if old.exists()
62 && !new.exists()
63 && std::fs::rename(&old, &new).is_err()
64 && std::fs::copy(&old, &new).is_ok()
65 {
66 let _ = std::fs::remove_file(&old);
67 }
68 }
69}
70
71pub const GRAPH_ENGINE_VERSION: u32 = 8;
96
97pub fn engine_outdated(project_root: &str) -> bool {
106 load_meta(project_root).is_none_or(|m| m.engine_version != GRAPH_ENGINE_VERSION)
107}
108
109const STORED_COUNTS_BUSY: std::time::Duration = std::time::Duration::from_millis(250);
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct StoredCounts {
115 pub files: u64,
117 pub nodes: u64,
119}
120
121pub fn stored_counts(project_root: &str) -> Option<StoredCounts> {
131 let db_path = graph_dir(project_root).join("graph.db");
132 if !db_path.is_file() {
133 return None;
134 }
135 stored_counts_at(&db_path, STORED_COUNTS_BUSY)
136}
137
138fn stored_counts_at(db_path: &Path, busy: std::time::Duration) -> Option<StoredCounts> {
139 use rusqlite::OpenFlags;
140 let conn = Connection::open_with_flags(
141 db_path,
142 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
143 )
144 .ok()?;
145 conn.busy_timeout(busy).ok()?;
146 conn.execute_batch("PRAGMA query_only = ON;").ok()?;
147 let count = |table: &str| {
148 conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
149 row.get::<_, i64>(0)
150 })
151 .ok()
152 .and_then(|n| u64::try_from(n).ok())
153 };
154 Some(StoredCounts {
155 files: count("file_catalog")?,
156 nodes: count("nodes")?,
157 })
158}
159
160fn is_transient_lock(err: &anyhow::Error) -> bool {
165 use rusqlite::ErrorCode;
166 if let Some(rusqlite::Error::SqliteFailure(e, _)) = err.downcast_ref::<rusqlite::Error>()
167 && matches!(e.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
168 {
169 return true;
170 }
171 let msg = format!("{err:#}").to_ascii_lowercase();
174 msg.contains("database is locked") || msg.contains("table is locked")
175}
176
177pub struct CodeGraph {
178 conn: Connection,
179 db_path: PathBuf,
180 gate: Option<served::GraphGate>,
183}
184
185impl CodeGraph {
186 pub fn open(project_root: &str) -> anyhow::Result<Self> {
187 let db_dir = graph_dir(project_root);
188 std::fs::create_dir_all(&db_dir)?;
189 migrate_if_needed(project_root, &db_dir);
190 let db_path = db_dir.join("graph.db");
191
192 const MAX_ATTEMPTS: u32 = 12;
199 let mut attempt = 0;
200 loop {
201 attempt += 1;
202 match Self::try_open(&db_path) {
203 Ok(mut graph) => {
204 graph.gate = Some(served::GraphGate::for_root(project_root));
205 return Ok(graph);
206 }
207 Err(e) if attempt < MAX_ATTEMPTS && is_transient_lock(&e) => {
208 std::thread::sleep(std::time::Duration::from_millis(40 * u64::from(attempt)));
209 }
210 Err(e) => return Err(e),
211 }
212 }
213 }
214
215 fn try_open(db_path: &Path) -> anyhow::Result<Self> {
217 let conn = Connection::open(db_path)?;
218 conn.busy_timeout(std::time::Duration::from_secs(5))?;
219 schema::initialize(&conn)?;
220 Ok(Self {
221 conn,
222 db_path: db_path.to_path_buf(),
223 gate: None,
224 })
225 }
226
227 pub fn open_in_memory() -> anyhow::Result<Self> {
228 let conn = Connection::open_in_memory()?;
229 schema::initialize(&conn)?;
230 Ok(Self {
231 conn,
232 db_path: PathBuf::from(":memory:"),
233 gate: None,
234 })
235 }
236
237 pub fn db_path(&self) -> &Path {
238 &self.db_path
239 }
240
241 pub fn connection(&self) -> &Connection {
242 &self.conn
243 }
244
245 pub fn upsert_node(&self, node: &Node) -> anyhow::Result<i64> {
246 node::upsert(&self.conn, node)
247 }
248
249 pub fn upsert_edge(&self, edge: &Edge) -> anyhow::Result<()> {
250 edge::upsert(&self.conn, edge)
251 }
252
253 pub fn get_node_by_path(&self, file_path: &str) -> anyhow::Result<Option<Node>> {
254 node::get_by_path(&self.conn, file_path)
255 }
256
257 pub fn get_node_by_symbol(&self, name: &str, file_path: &str) -> anyhow::Result<Option<Node>> {
258 node::get_by_symbol(&self.conn, name, file_path)
259 }
260
261 pub fn remove_file_nodes(&self, file_path: &str) -> anyhow::Result<()> {
262 node::remove_by_file(&self.conn, file_path)
263 }
264
265 pub fn edges_from(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
266 edge::from_node(&self.conn, node_id)
267 }
268
269 pub fn edges_to(&self, node_id: i64) -> anyhow::Result<Vec<Edge>> {
270 edge::to_node(&self.conn, node_id)
271 }
272
273 pub fn dependents(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
278 Ok(self.served_neighbours(file_path, queries::dependents(&self.conn, file_path)?))
279 }
280
281 pub fn dependencies(&self, file_path: &str) -> anyhow::Result<Vec<String>> {
282 Ok(self.served_neighbours(file_path, queries::dependencies(&self.conn, file_path)?))
283 }
284
285 pub fn impact_analysis(
286 &self,
287 file_path: &str,
288 max_depth: usize,
289 ) -> anyhow::Result<ImpactResult> {
290 queries::impact_analysis(&self.conn, file_path, max_depth, &|path: &str| {
291 self.serves_file(path)
292 })
293 }
294
295 pub fn dependency_chain(
296 &self,
297 from: &str,
298 to: &str,
299 ) -> anyhow::Result<Option<DependencyChain>> {
300 queries::dependency_chain(&self.conn, from, to, &|path: &str| self.serves_file(path))
301 }
302
303 pub fn related_files(
304 &self,
305 file_path: &str,
306 limit: usize,
307 ) -> anyhow::Result<Vec<(String, f64)>> {
308 queries::related_files(&self.conn, file_path, limit, &|path: &str| {
309 self.serves_file(path)
310 })
311 }
312
313 pub fn file_connectivity(
314 &self,
315 file_path: &str,
316 ) -> anyhow::Result<std::collections::HashMap<String, (usize, usize)>> {
317 queries::file_connectivity(&self.conn, file_path, &|path: &str| self.serves_file(path))
318 }
319
320 pub fn node_count(&self) -> anyhow::Result<usize> {
321 node::count(&self.conn)
322 }
323
324 pub fn edge_count(&self) -> anyhow::Result<usize> {
325 edge::count(&self.conn)
326 }
327
328 pub fn semantic_lookup(
333 &self,
334 site: semantic_cache::SiteKey<'_>,
335 caller_hash: &str,
336 ) -> anyhow::Result<Option<CachedResolution>> {
337 semantic_cache::lookup_served(&self.conn, site, caller_hash, |file| self.serves_file(file))
338 }
339
340 pub fn semantic_store(
345 &self,
346 site: semantic_cache::SiteKey<'_>,
347 caller_hash: &str,
348 value: &CachedResolution,
349 admission: &crate::core::context_admission::stores::StoreAdmission,
350 ) -> anyhow::Result<bool> {
351 semantic_cache::store_admitted(&self.conn, site, caller_hash, value, admission)
352 }
353
354 pub fn has_semantic_answers(&self) -> anyhow::Result<bool> {
356 semantic_cache::any(&self.conn)
357 }
358
359 pub fn semantic_prune(
361 &self,
362 live_hashes: &std::collections::HashMap<String, String>,
363 ) -> anyhow::Result<usize> {
364 semantic_cache::prune(&self.conn, live_hashes)
365 }
366
367 pub fn file_edges_of_kind(
370 &self,
371 kind: &EdgeKind,
372 ) -> anyhow::Result<Vec<(String, String, Option<String>)>> {
373 edge::file_edges_of_kind(&self.conn, kind)
374 }
375
376 pub fn upsert_edge_with_evidence(
381 &self,
382 source_id: i64,
383 target_id: i64,
384 kind: &EdgeKind,
385 evidence: &crate::core::semantic::EdgeEvidence,
386 ) -> anyhow::Result<()> {
387 use crate::core::semantic::EdgeEvidence;
388 self.atomically(|| {
389 let existing = edge::metadata_of(&self.conn, source_id, target_id, kind)?;
390 let merged = EdgeEvidence::merge(
391 EdgeEvidence::from_metadata(existing.as_deref()),
392 evidence.clone(),
393 );
394 edge::upsert(
395 &self.conn,
396 &Edge::new(source_id, target_id, kind.clone()).with_metadata(&merged.to_metadata()),
397 )
398 })
399 }
400
401 fn atomically<T>(&self, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
406 if !self.conn.is_autocommit() {
407 return f();
408 }
409 self.conn.execute_batch("BEGIN IMMEDIATE")?;
410 let outcome = f().and_then(|value| {
411 self.conn.execute_batch("COMMIT")?;
412 Ok(value)
413 });
414 if outcome.is_err() && !self.conn.is_autocommit() {
418 let _ = self.conn.execute_batch("ROLLBACK");
419 }
420 outcome
421 }
422
423 pub fn withdraw_file_edge(
427 &self,
428 source: &str,
429 target: &str,
430 kind: &EdgeKind,
431 origin: crate::core::semantic::EvidenceOrigin,
432 ) -> anyhow::Result<()> {
433 use crate::core::semantic::EdgeEvidence;
434 let (Some(s), Some(t)) = (
435 self.get_node_by_path(source)?.and_then(|n| n.id),
436 self.get_node_by_path(target)?.and_then(|n| n.id),
437 ) else {
438 return Ok(());
439 };
440 self.atomically(|| {
441 let existing = edge::metadata_of(&self.conn, s, t, kind)?;
442 let Some(evidence) = EdgeEvidence::from_metadata(existing.as_deref()) else {
443 return Ok(());
444 };
445 if !evidence.has(origin) {
446 return Ok(());
447 }
448 match evidence.without(origin) {
449 Some(rest) => edge::upsert(
450 &self.conn,
451 &Edge::new(s, t, kind.clone()).with_metadata(&rest.to_metadata()),
452 ),
453 None => edge::remove_file_edge(&self.conn, source, target, kind),
454 }
455 })
456 }
457
458 pub fn upsert_cross_source_edge(
462 &self,
463 from: &str,
464 to: &str,
465 kind: &str,
466 weight: f32,
467 ) -> anyhow::Result<()> {
468 cross_source::upsert(&self.conn, from, to, kind, weight)
469 }
470
471 pub fn all_cross_source_edges(&self) -> Vec<crate::core::graph_index::IndexEdge> {
473 cross_source::all(&self.conn).unwrap_or_default()
474 }
475
476 pub fn cross_source_edge_count(&self) -> anyhow::Result<usize> {
477 cross_source::count(&self.conn)
478 }
479
480 pub fn delete_cross_source_edges_by_kind(&self, kind: &str) -> anyhow::Result<usize> {
484 cross_source::delete_by_kind(&self.conn, kind)
485 }
486
487 pub fn delete_cross_source_edges_with_uri_prefix(&self, prefix: &str) -> anyhow::Result<usize> {
490 cross_source::delete_with_uri_prefix(&self.conn, prefix)
491 }
492
493 pub fn clear(&self) -> anyhow::Result<()> {
494 self.conn.execute_batch(
495 "DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog; DELETE FROM paths; \
496 DELETE FROM cross_source_edges;",
497 )?;
498 Ok(())
499 }
500
501 pub fn clear_code_graph(&self) -> anyhow::Result<()> {
506 self.conn.execute_batch(
507 "DELETE FROM edges; DELETE FROM nodes; DELETE FROM file_catalog; DELETE FROM paths;",
508 )?;
509 Ok(())
510 }
511
512 pub fn upsert_file_catalog(&self, entry: &FileCatalogEntry) -> anyhow::Result<()> {
513 file_catalog::upsert(&self.conn, entry)
514 }
515
516 pub fn get_file_catalog(&self, path: &str) -> anyhow::Result<Option<FileCatalogEntry>> {
517 file_catalog::get(&self.conn, path)
518 }
519
520 pub fn file_catalog_count(&self) -> anyhow::Result<usize> {
521 file_catalog::count(&self.conn)
522 }
523
524 pub fn file_catalog_paths(&self) -> anyhow::Result<Vec<String>> {
525 file_catalog::all_paths(&self.conn)
526 }
527
528 pub fn find_symbols(
529 &self,
530 name: &str,
531 file_filter: Option<&str>,
532 kind_filter: Option<&str>,
533 ) -> anyhow::Result<Vec<Node>> {
534 node::find_symbols(&self.conn, name, file_filter, kind_filter)
535 }
536
537 pub fn resolve_symbol_def_files(&self, name: &str) -> anyhow::Result<Vec<String>> {
540 node::resolve_symbol_def_files(&self.conn, name)
541 }
542
543 pub fn symbol_count(&self) -> anyhow::Result<usize> {
544 node::symbol_count(&self.conn)
545 }
546
547 pub fn file_node_count(&self) -> anyhow::Result<usize> {
550 node::file_count(&self.conn)
551 }
552
553 pub fn all_symbols(&self) -> anyhow::Result<Vec<Node>> {
556 node::all_symbols(&self.conn)
557 }
558
559 pub fn all_edges_flat(&self) -> anyhow::Result<Vec<(String, String, String, f64)>> {
560 node::all_edges_flat(&self.conn)
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::core::data_dir::test_env_lock;
568
569 fn test_graph() -> CodeGraph {
570 CodeGraph::open_in_memory().unwrap()
571 }
572
573 #[test]
574 fn create_and_query_nodes() {
575 let g = test_graph();
576
577 let id = g.upsert_node(&Node::file("src/main.rs")).unwrap();
578 assert!(id > 0);
579
580 let found = g.get_node_by_path("src/main.rs").unwrap();
581 assert!(found.is_some());
582 assert_eq!(found.unwrap().file_path, "src/main.rs");
583 }
584
585 #[test]
586 fn create_and_query_edges() {
587 let g = test_graph();
588
589 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
590 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
591
592 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
593
594 let from_a = g.edges_from(a).unwrap();
595 assert_eq!(from_a.len(), 1);
596 assert_eq!(from_a[0].target_id, b);
597
598 let to_b = g.edges_to(b).unwrap();
599 assert_eq!(to_b.len(), 1);
600 assert_eq!(to_b[0].source_id, a);
601 }
602
603 #[test]
604 fn dependents_query() {
605 let g = test_graph();
606
607 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
608 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
609 let utils = g.upsert_node(&Node::file("src/utils.rs")).unwrap();
610
611 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
612 .unwrap();
613 g.upsert_edge(&Edge::new(utils, lib, EdgeKind::Imports))
614 .unwrap();
615
616 let deps = g.dependents("src/lib.rs").unwrap();
617 assert_eq!(deps.len(), 2);
618 assert!(deps.contains(&"src/main.rs".to_string()));
619 assert!(deps.contains(&"src/utils.rs".to_string()));
620 }
621
622 #[test]
623 fn dependencies_query() {
624 let g = test_graph();
625
626 let main = g.upsert_node(&Node::file("src/main.rs")).unwrap();
627 let lib = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
628 let config = g.upsert_node(&Node::file("src/config.rs")).unwrap();
629
630 g.upsert_edge(&Edge::new(main, lib, EdgeKind::Imports))
631 .unwrap();
632 g.upsert_edge(&Edge::new(main, config, EdgeKind::Imports))
633 .unwrap();
634
635 let deps = g.dependencies("src/main.rs").unwrap();
636 assert_eq!(deps.len(), 2);
637 }
638
639 #[test]
640 #[allow(clippy::many_single_char_names)] fn impact_analysis_depth() {
642 let g = test_graph();
643
644 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
645 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
646 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
647 let d = g.upsert_node(&Node::file("d.rs")).unwrap();
648
649 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
650 g.upsert_edge(&Edge::new(c, b, EdgeKind::Imports)).unwrap();
651 g.upsert_edge(&Edge::new(d, c, EdgeKind::Imports)).unwrap();
652
653 let impact = g.impact_analysis("a.rs", 2).unwrap();
654 assert!(impact.affected_files.contains(&"b.rs".to_string()));
655 assert!(impact.affected_files.contains(&"c.rs".to_string()));
656 assert!(!impact.affected_files.contains(&"d.rs".to_string()));
657
658 let deep = g.impact_analysis("a.rs", 10).unwrap();
659 assert!(deep.affected_files.contains(&"d.rs".to_string()));
660 }
661
662 #[test]
666 #[allow(clippy::many_single_char_names)] fn impact_propagates_the_heaviest_path_and_marks_guess_only_reach() {
668 use crate::core::semantic::{EdgeEvidence, EvidenceGrade, EvidenceOrigin};
669 let g = test_graph();
670 let id = |p: &str| g.upsert_node(&Node::file(p)).unwrap();
671 let (a, b, c, x, y, z) = (id("a"), id("b"), id("c"), id("x"), id("y"), id("z"));
672 let edge = |from, to, kind| g.upsert_edge(&Edge::new(from, to, kind)).unwrap();
673 let guess = EdgeEvidence::new(
677 EvidenceGrade::HeuristicStructural,
678 EvidenceOrigin::Enrichment,
679 None,
680 1,
681 );
682 g.upsert_edge_with_evidence(b, a, &EdgeKind::Calls, &guess)
683 .unwrap();
684 edge(b, a, EdgeKind::Sibling);
685 edge(c, b, EdgeKind::Cochange);
686 edge(x, a, EdgeKind::Sibling);
689 edge(y, a, EdgeKind::Imports);
690 edge(x, y, EdgeKind::Imports);
691 edge(z, x, EdgeKind::Cochange);
692
693 let impact = g.impact_analysis("a", 5).unwrap();
694 assert_eq!(impact.affected_files, ["b", "c", "x", "y", "z"]);
695 assert_eq!(impact.weak_files, ["c"]);
696 assert_eq!(impact.max_depth_reached, 3);
697 }
698
699 #[test]
700 fn upsert_idempotent() {
701 let g = test_graph();
702
703 let id1 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
704 let id2 = g.upsert_node(&Node::file("src/main.rs")).unwrap();
705 assert_eq!(id1, id2);
706 assert_eq!(g.node_count().unwrap(), 1);
707 }
708
709 #[test]
710 fn remove_file_cascades() {
711 let g = test_graph();
712
713 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
714 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
715 let sym = g
716 .upsert_node(&Node::symbol("MyStruct", "src/a.rs", NodeKind::Symbol))
717 .unwrap();
718
719 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
720 g.upsert_edge(&Edge::new(sym, b, EdgeKind::Calls)).unwrap();
721
722 g.remove_file_nodes("src/a.rs").unwrap();
723
724 assert!(g.get_node_by_path("src/a.rs").unwrap().is_none());
725 assert_eq!(g.edge_count().unwrap(), 0);
726 }
727
728 #[test]
729 fn dependency_chain_found() {
730 let g = test_graph();
731
732 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
733 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
734 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
735
736 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
737 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
738
739 let chain = g.dependency_chain("a.rs", "c.rs").unwrap();
740 assert!(chain.is_some());
741 let chain = chain.unwrap();
742 assert_eq!(chain.path, vec!["a.rs", "b.rs", "c.rs"]);
743 }
744
745 #[test]
746 fn counts() {
747 let g = test_graph();
748 assert_eq!(g.node_count().unwrap(), 0);
749 assert_eq!(g.edge_count().unwrap(), 0);
750
751 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
752 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
753 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
754
755 assert_eq!(g.node_count().unwrap(), 2);
756 assert_eq!(g.edge_count().unwrap(), 1);
757 }
758
759 #[test]
760 fn multi_edge_dependents() {
761 let g = test_graph();
762
763 let a = g.upsert_node(&Node::file("src/a.rs")).unwrap();
764 let b = g.upsert_node(&Node::file("src/b.rs")).unwrap();
765 let c = g.upsert_node(&Node::file("src/c.rs")).unwrap();
766
767 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
768 g.upsert_edge(&Edge::new(c, a, EdgeKind::Calls)).unwrap();
769
770 let deps = g.dependents("src/a.rs").unwrap();
771 assert_eq!(deps.len(), 2);
772 assert!(deps.contains(&"src/b.rs".to_string()));
773 assert!(deps.contains(&"src/c.rs".to_string()));
774 }
775
776 #[test]
777 fn multi_edge_impact_analysis() {
778 let g = test_graph();
779
780 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
781 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
782 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
783
784 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
785 g.upsert_edge(&Edge::new(c, b, EdgeKind::Calls)).unwrap();
786
787 let impact = g.impact_analysis("a.rs", 10).unwrap();
788 assert!(impact.affected_files.contains(&"b.rs".to_string()));
789 assert!(impact.affected_files.contains(&"c.rs".to_string()));
790 }
791
792 #[test]
793 fn related_files_scored() {
794 let g = test_graph();
795
796 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
797 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
798 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
799
800 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
801 g.upsert_edge(&Edge::new(a, b, EdgeKind::Calls)).unwrap();
802 g.upsert_edge(&Edge::new(a, c, EdgeKind::TypeRef)).unwrap();
803
804 let related = g.related_files("a.rs", 10).unwrap();
805 assert_eq!(related.len(), 2);
806 let b_score = related.iter().find(|(p, _)| p == "b.rs").unwrap().1;
807 let c_score = related.iter().find(|(p, _)| p == "c.rs").unwrap().1;
808 assert!(
809 b_score > c_score,
810 "b.rs has imports+calls, should rank higher than c.rs with type_ref"
811 );
812 }
813
814 #[test]
818 fn related_files_counts_each_relationship_once_weighted_by_evidence() {
819 use crate::core::semantic::{EdgeEvidence, EvidenceGrade, EvidenceOrigin};
820 let g = test_graph();
821 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
822 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
823 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
824 let evidence = |grade| EdgeEvidence::new(grade, EvidenceOrigin::Enrichment, None, 1);
825
826 g.upsert_edge_with_evidence(
828 a,
829 b,
830 &EdgeKind::Calls,
831 &evidence(EvidenceGrade::VerifiedSemantic),
832 )
833 .unwrap();
834 for i in 0..3 {
835 let caller = g
836 .upsert_node(&Node::symbol(&format!("f{i}"), "a.rs", NodeKind::Symbol))
837 .unwrap();
838 let callee = g
839 .upsert_node(&Node::symbol(&format!("g{i}"), "b.rs", NodeKind::Symbol))
840 .unwrap();
841 g.upsert_edge_with_evidence(
842 caller,
843 callee,
844 &EdgeKind::Calls,
845 &evidence(EvidenceGrade::ResolvedStructural),
846 )
847 .unwrap();
848 }
849 g.upsert_edge_with_evidence(
851 a,
852 c,
853 &EdgeKind::Calls,
854 &evidence(EvidenceGrade::HeuristicStructural),
855 )
856 .unwrap();
857
858 let related = g.related_files("a.rs", 10).unwrap();
859 let score = |p: &str| related.iter().find(|(f, _)| f == p).unwrap().1;
860 assert!(
861 (score("b.rs") - edge_weight("calls")).abs() < 1e-9,
862 "counted once"
863 );
864 assert!(score("b.rs") > score("c.rs"), "verified outranks heuristic");
865 }
866
867 #[test]
868 fn graph_dir_uses_data_dir_when_set() {
869 let tmp = tempfile::tempdir().unwrap();
870 let project = tmp.path().join("myproject");
871 std::fs::create_dir_all(&project).unwrap();
872
873 let data_dir = tmp.path().join("data");
874 std::fs::create_dir_all(&data_dir).unwrap();
875
876 let _guard = test_env_lock();
877 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
878
879 let dir = graph_dir(project.to_str().unwrap());
880 assert!(dir.starts_with(&data_dir));
881 assert!(dir.to_string_lossy().contains("graphs"));
882
883 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
884 }
885
886 #[test]
887 fn graph_dir_returns_consistent_hash_dir() {
888 let tmp = tempfile::tempdir().unwrap();
889 let project = tmp.path().join("hash_project");
890 std::fs::create_dir_all(&project).unwrap();
891
892 let data_dir = tmp.path().join("data2");
893 std::fs::create_dir_all(&data_dir).unwrap();
894
895 let _guard = test_env_lock();
896 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
897
898 let dir1 = graph_dir(project.to_str().unwrap());
899 let dir2 = graph_dir(project.to_str().unwrap());
900 assert_eq!(dir1, dir2, "graph_dir should be deterministic");
901 assert!(dir1.to_string_lossy().contains("graphs"));
902
903 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
904 }
905
906 #[test]
907 fn migration_moves_old_files() {
908 let tmp = tempfile::tempdir().unwrap();
909 let project = tmp.path().join("migtest");
910 let old_dir = project.join(".lean-ctx");
911 std::fs::create_dir_all(&old_dir).unwrap();
912 std::fs::write(old_dir.join("graph.db"), b"old-db-content").unwrap();
913 std::fs::write(old_dir.join("graph.meta.json"), b"old-meta").unwrap();
914
915 let new_dir = tmp.path().join("newloc");
916 std::fs::create_dir_all(&new_dir).unwrap();
917
918 migrate_if_needed(project.to_str().unwrap(), &new_dir);
919
920 assert!(new_dir.join("graph.db").exists());
921 assert!(new_dir.join("graph.meta.json").exists());
922 assert!(!old_dir.join("graph.db").exists());
923 assert!(!old_dir.join("graph.meta.json").exists());
924 assert_eq!(
925 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
926 "old-db-content"
927 );
928 }
929
930 #[test]
931 fn migration_skips_when_new_exists() {
932 let tmp = tempfile::tempdir().unwrap();
933 let project = tmp.path().join("skiptest");
934 let old_dir = project.join(".lean-ctx");
935 std::fs::create_dir_all(&old_dir).unwrap();
936 std::fs::write(old_dir.join("graph.db"), b"old").unwrap();
937
938 let new_dir = tmp.path().join("newloc2");
939 std::fs::create_dir_all(&new_dir).unwrap();
940 std::fs::write(new_dir.join("graph.db"), b"already-there").unwrap();
941
942 migrate_if_needed(project.to_str().unwrap(), &new_dir);
943
944 assert_eq!(
945 std::fs::read_to_string(new_dir.join("graph.db")).unwrap(),
946 "already-there"
947 );
948 assert!(old_dir.join("graph.db").exists());
949 }
950
951 #[test]
952 fn open_with_data_dir() {
953 let tmp = tempfile::tempdir().unwrap();
954 let project = tmp.path().join("opentest");
955 std::fs::create_dir_all(&project).unwrap();
956
957 let data_dir = tmp.path().join("xdata");
958 std::fs::create_dir_all(&data_dir).unwrap();
959
960 let _guard = test_env_lock();
961 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
962
963 let g = CodeGraph::open(project.to_str().unwrap()).unwrap();
964 assert!(g.db_path().starts_with(&data_dir));
965 assert!(g.db_path().to_string_lossy().contains("graph.db"));
966
967 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
968 }
969
970 #[test]
971 fn meta_path_uses_graph_dir() {
972 let tmp = tempfile::tempdir().unwrap();
973 let project = tmp.path().join("metatest");
974 std::fs::create_dir_all(&project).unwrap();
975
976 let data_dir = tmp.path().join("mdata");
977 std::fs::create_dir_all(&data_dir).unwrap();
978
979 let _guard = test_env_lock();
980 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.to_str().unwrap());
981
982 let mp = meta::meta_path(project.to_str().unwrap());
983 assert!(mp.starts_with(&data_dir));
984 assert!(mp.to_string_lossy().contains("graph.meta.json"));
985
986 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
987 }
988
989 #[test]
990 fn engine_outdated_flags_old_and_missing_meta() {
991 let _iso = crate::core::data_dir::isolated_data_dir();
992 let proj = tempfile::tempdir().unwrap();
993 let root = proj.path().to_str().unwrap();
994
995 assert!(engine_outdated(root), "missing meta must read as outdated");
997
998 let mut meta = PropertyGraphMetaV1 {
1000 built_at: "2026-01-01T00:00:00Z".to_string(),
1001 engine_version: 0,
1002 ..Default::default()
1003 };
1004 write_meta(root, &meta).unwrap();
1005 assert!(
1006 engine_outdated(root),
1007 "engine_version 0 must read as outdated"
1008 );
1009
1010 meta.engine_version = GRAPH_ENGINE_VERSION;
1012 write_meta(root, &meta).unwrap();
1013 assert!(
1014 !engine_outdated(root),
1015 "current engine_version must read as up to date"
1016 );
1017
1018 meta.engine_version = GRAPH_ENGINE_VERSION + 1;
1020 write_meta(root, &meta).unwrap();
1021 assert!(
1022 engine_outdated(root),
1023 "a graph of a newer engine must not be read by this one"
1024 );
1025 }
1026
1027 fn catalog_entry(path: &str) -> FileCatalogEntry {
1028 FileCatalogEntry {
1029 path: path.to_string(),
1030 hash: format!("hash-{path}"),
1031 language: "rs".to_string(),
1032 line_count: 1,
1033 token_count: 1,
1034 exports: Vec::new(),
1035 summary: String::new(),
1036 }
1037 }
1038
1039 #[test]
1042 fn stored_counts_reads_rows_and_never_creates_a_graph() {
1043 let _iso = crate::core::data_dir::isolated_data_dir();
1044 let proj = tempfile::tempdir().unwrap();
1045 let root = proj.path().to_str().unwrap();
1046
1047 assert_eq!(stored_counts(root), None);
1048 assert!(
1049 !graph_dir(root).join("graph.db").exists(),
1050 "a status read must not create graph.db"
1051 );
1052
1053 let g = CodeGraph::open(root).unwrap();
1054 g.upsert_file_catalog(&catalog_entry("src/a.rs")).unwrap();
1055 g.upsert_file_catalog(&catalog_entry("src/b.rs")).unwrap();
1056 g.upsert_node(&Node::file("src/a.rs")).unwrap();
1057 drop(g);
1058
1059 assert_eq!(
1060 stored_counts(root),
1061 Some(StoredCounts { files: 2, nodes: 1 })
1062 );
1063 }
1064
1065 #[test]
1068 fn stored_counts_gives_up_on_a_held_graph_within_its_bound() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let db = dir.path().join("graph.db");
1071 {
1072 let conn = Connection::open(&db).unwrap();
1073 schema::initialize(&conn).unwrap();
1074 }
1075 assert_eq!(
1076 stored_counts_at(&db, STORED_COUNTS_BUSY),
1077 Some(StoredCounts { files: 0, nodes: 0 })
1078 );
1079
1080 let holder = Connection::open(&db).unwrap();
1081 holder
1082 .execute_batch(
1083 "PRAGMA locking_mode = EXCLUSIVE;
1084 BEGIN EXCLUSIVE;
1085 INSERT INTO paths(path) VALUES ('held');",
1086 )
1087 .unwrap();
1088
1089 let started = std::time::Instant::now();
1090 let busy = std::time::Duration::from_millis(100);
1091 assert_eq!(stored_counts_at(&db, busy), None, "a held graph is busy");
1092 assert!(
1093 started.elapsed() < std::time::Duration::from_secs(5),
1094 "waited {:?} on a held graph",
1095 started.elapsed()
1096 );
1097 holder.execute_batch("ROLLBACK;").unwrap();
1098 }
1099
1100 #[test]
1103 fn stored_counts_leaves_a_legacy_schema_untouched() {
1104 let dir = tempfile::tempdir().unwrap();
1105 let db = dir.path().join("graph.db");
1106 {
1107 let conn = Connection::open(&db).unwrap();
1108 conn.execute_batch(
1109 "CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_path TEXT);
1110 INSERT INTO nodes(file_path) VALUES ('src/a.rs');",
1111 )
1112 .unwrap();
1113 }
1114 assert_eq!(stored_counts_at(&db, STORED_COUNTS_BUSY), None);
1115 let conn = Connection::open(&db).unwrap();
1116 let kept: i64 = conn
1117 .query_row("SELECT count(*) FROM nodes", [], |r| r.get(0))
1118 .unwrap();
1119 assert_eq!(kept, 1, "the legacy table must survive a status read");
1120 }
1121}