1use std::path::Path;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use super::graph_index::{self, ProjectIndex};
5use super::property_graph::CodeGraph;
6
7static GRAPH_BUILD_TRIGGERED: AtomicBool = AtomicBool::new(false);
8
9#[derive(Debug, Clone)]
10pub struct SymbolInfo {
11 pub name: String,
12 pub file: String,
13 pub kind: String,
14 pub start_line: usize,
15 pub end_line: usize,
16 pub is_exported: bool,
17}
18
19fn index_edge_kind(pg_kind: &str) -> String {
27 match pg_kind {
28 "imports" => "import".to_string(),
29 other => other.to_string(),
30 }
31}
32
33fn line_distance(start: usize, target: Option<usize>) -> usize {
37 match target {
38 Some(t) => start.abs_diff(t),
39 None => 0,
40 }
41}
42
43fn symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
49 let (meta_kind, meta_exported) =
50 super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
51 SymbolInfo {
52 kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
53 is_exported: meta_exported.unwrap_or(true),
54 name: n.name,
55 file: n.file_path,
56 start_line: n.line_start.unwrap_or(0),
57 end_line: n.line_end.unwrap_or(0),
58 }
59}
60
61#[derive(Debug, Clone)]
62pub struct EdgeInfo {
63 pub from: String,
64 pub to: String,
65 pub kind: String,
66 pub weight: f64,
67}
68
69#[derive(Debug, Clone)]
70pub struct FileInfo {
71 pub path: String,
72 pub hash: String,
73 pub language: String,
74 pub line_count: usize,
75 pub token_count: usize,
76 pub exports: Vec<String>,
77 pub summary: String,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum GraphProviderSource {
82 PropertyGraph,
83 GraphIndex,
84}
85
86pub enum GraphProvider {
87 PropertyGraph(CodeGraph),
88 GraphIndex(ProjectIndex),
89}
90
91pub struct OpenGraphProvider {
92 pub source: GraphProviderSource,
93 pub provider: GraphProvider,
94}
95
96impl GraphProvider {
97 pub fn node_count(&self) -> Option<usize> {
98 match self {
99 GraphProvider::PropertyGraph(g) => g.node_count().ok(),
100 GraphProvider::GraphIndex(i) => Some(i.file_count()),
101 }
102 }
103
104 pub fn edge_count(&self) -> Option<usize> {
105 match self {
106 GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
107 GraphProvider::GraphIndex(i) => Some(i.edge_count()),
108 }
109 }
110
111 pub fn dependencies(&self, file_path: &str) -> Vec<String> {
112 match self {
113 GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
114 GraphProvider::GraphIndex(i) => i
115 .edges
116 .iter()
117 .filter(|e| e.kind == "import" && e.from == file_path)
118 .map(|e| e.to.clone())
119 .collect(),
120 }
121 }
122
123 pub fn dependents(&self, file_path: &str) -> Vec<String> {
124 match self {
125 GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
126 GraphProvider::GraphIndex(i) => i
127 .edges
128 .iter()
129 .filter(|e| e.kind == "import" && e.to == file_path)
130 .map(|e| e.from.clone())
131 .collect(),
132 }
133 }
134
135 pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
136 match self {
137 GraphProvider::PropertyGraph(g) => g
138 .impact_analysis(file_path, depth)
139 .map(|r| r.affected_files)
140 .unwrap_or_default(),
141 GraphProvider::GraphIndex(i) => i.get_related(file_path, depth),
142 }
143 }
144
145 pub fn file_paths(&self) -> Vec<String> {
146 match self {
147 GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
148 GraphProvider::GraphIndex(i) => {
149 let mut paths: Vec<String> = i.files.keys().cloned().collect();
150 paths.sort();
151 paths
152 }
153 }
154 }
155
156 pub fn file_count(&self) -> usize {
157 match self {
158 GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
159 GraphProvider::GraphIndex(i) => i.files.len(),
160 }
161 }
162
163 pub fn symbol_count(&self) -> usize {
164 match self {
165 GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
166 GraphProvider::GraphIndex(i) => i.symbols.len(),
167 }
168 }
169
170 pub fn find_symbols(
171 &self,
172 name: &str,
173 file_filter: Option<&str>,
174 kind_filter: Option<&str>,
175 ) -> Vec<SymbolInfo> {
176 match self {
177 GraphProvider::PropertyGraph(g) => g
178 .find_symbols(name, file_filter, None)
187 .unwrap_or_default()
188 .into_iter()
189 .map(symbol_info_from_node)
190 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
191 .collect(),
192 GraphProvider::GraphIndex(i) => {
193 let name_lower = name.to_lowercase();
194 i.symbols
195 .values()
196 .filter(|s| s.name.to_lowercase().contains(&name_lower))
197 .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
198 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
199 .take(100)
200 .map(|s| SymbolInfo {
201 name: s.name.clone(),
202 file: s.file.clone(),
203 kind: s.kind.clone(),
204 start_line: s.start_line,
205 end_line: s.end_line,
206 is_exported: s.is_exported,
207 })
208 .collect()
209 }
210 }
211 }
212
213 pub fn all_symbols(&self) -> Vec<SymbolInfo> {
217 match self {
218 GraphProvider::PropertyGraph(g) => g
219 .all_symbols()
220 .unwrap_or_default()
221 .into_iter()
222 .map(symbol_info_from_node)
223 .collect(),
224 GraphProvider::GraphIndex(i) => i
225 .symbols
226 .values()
227 .map(|s| SymbolInfo {
228 name: s.name.clone(),
229 file: s.file.clone(),
230 kind: s.kind.clone(),
231 start_line: s.start_line,
232 end_line: s.end_line,
233 is_exported: s.is_exported,
234 })
235 .collect(),
236 }
237 }
238
239 pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
240 match self {
241 GraphProvider::PropertyGraph(g) => {
242 let parts: Vec<&str> = key.splitn(2, "::").collect();
247 if parts.len() != 2 {
248 return None;
249 }
250 let (file_path, sym_name) = (parts[0], parts[1]);
251 g.get_node_by_symbol(sym_name, file_path)
252 .ok()
253 .flatten()
254 .map(symbol_info_from_node)
255 }
256 GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
257 name: s.name.clone(),
258 file: s.file.clone(),
259 kind: s.kind.clone(),
260 start_line: s.start_line,
261 end_line: s.end_line,
262 is_exported: s.is_exported,
263 }),
264 }
265 }
266
267 pub fn find_symbol_by_handle(
282 &self,
283 handle: &crate::core::handle::SymbolHandle,
284 ) -> Option<SymbolInfo> {
285 let key = format!("{}::{}", handle.path, handle.name);
286 if let Some(sym) = self.get_symbol(&key) {
287 return Some(sym);
288 }
289
290 let tail = handle
291 .name
292 .rsplit("::")
293 .next()
294 .unwrap_or(handle.name.as_str());
295 let mut candidates: Vec<SymbolInfo> = self
296 .all_symbols()
297 .into_iter()
298 .filter(|s| s.file == handle.path)
299 .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
300 .collect();
301 if candidates.is_empty() {
302 return None;
303 }
304 candidates.sort_by(|a, b| {
305 let exact_a = u8::from(a.name != handle.name);
306 let exact_b = u8::from(b.name != handle.name);
307 exact_a
308 .cmp(&exact_b)
309 .then_with(|| {
310 line_distance(a.start_line, handle.line)
311 .cmp(&line_distance(b.start_line, handle.line))
312 })
313 .then_with(|| a.start_line.cmp(&b.start_line))
314 .then_with(|| a.name.cmp(&b.name))
315 });
316 candidates.into_iter().next()
317 }
318
319 pub fn edges(&self) -> Vec<EdgeInfo> {
320 match self {
321 GraphProvider::PropertyGraph(g) => g
327 .all_edges_flat()
328 .unwrap_or_default()
329 .into_iter()
330 .map(|(from, to, kind, weight)| EdgeInfo {
331 from,
332 to,
333 kind: index_edge_kind(&kind),
334 weight,
335 })
336 .collect(),
337 GraphProvider::GraphIndex(i) => i
338 .edges
339 .iter()
340 .map(|e| EdgeInfo {
341 from: e.from.clone(),
342 to: e.to.clone(),
343 kind: e.kind.clone(),
344 weight: e.weight as f64,
345 })
346 .collect(),
347 }
348 }
349
350 pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
351 self.edges()
352 .into_iter()
353 .filter(|e| e.kind == kind)
354 .collect()
355 }
356
357 pub fn file_entries(&self) -> Vec<FileInfo> {
361 match self {
362 GraphProvider::PropertyGraph(_) => self
363 .file_paths()
364 .into_iter()
365 .filter_map(|p| self.get_file_entry(&p))
366 .collect(),
367 GraphProvider::GraphIndex(i) => i
368 .files
369 .values()
370 .map(|e| FileInfo {
371 path: e.path.clone(),
372 hash: e.hash.clone(),
373 language: e.language.clone(),
374 line_count: e.line_count,
375 token_count: e.token_count,
376 exports: e.exports.clone(),
377 summary: e.summary.clone(),
378 })
379 .collect(),
380 }
381 }
382
383 pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
384 match self {
385 GraphProvider::PropertyGraph(g) => {
386 g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
387 path: e.path,
388 hash: e.hash,
389 language: e.language,
390 line_count: e.line_count,
391 token_count: e.token_count,
392 exports: e.exports,
393 summary: e.summary,
394 })
395 }
396 GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
397 path: e.path.clone(),
398 hash: e.hash.clone(),
399 language: e.language.clone(),
400 line_count: e.line_count,
401 token_count: e.token_count,
402 exports: e.exports.clone(),
403 summary: e.summary.clone(),
404 }),
405 }
406 }
407
408 pub fn last_scan(&self) -> String {
409 match self {
410 GraphProvider::PropertyGraph(_) => String::new(),
411 GraphProvider::GraphIndex(i) => i.last_scan.clone(),
412 }
413 }
414
415 pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
424 if let GraphProvider::GraphIndex(i) = self {
425 return i.clone();
426 }
427 let mut idx = ProjectIndex::new(project_root);
428 if let Some(meta) = super::property_graph::load_meta(project_root)
433 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
434 {
435 idx.last_scan = dt
436 .with_timezone(&chrono::Local)
437 .format("%Y-%m-%d %H:%M:%S")
438 .to_string();
439 }
440 for f in self.file_entries() {
441 idx.files.insert(
442 f.path.clone(),
443 graph_index::FileEntry {
444 path: f.path,
445 hash: f.hash,
446 language: f.language,
447 line_count: f.line_count,
448 token_count: f.token_count,
449 exports: f.exports,
450 summary: f.summary,
451 },
452 );
453 }
454 for s in self.all_symbols() {
455 let key = format!("{}::{}", s.file, s.name);
456 idx.symbols.insert(
457 key,
458 graph_index::SymbolEntry {
459 file: s.file,
460 name: s.name,
461 kind: s.kind,
462 start_line: s.start_line,
463 end_line: s.end_line,
464 is_exported: s.is_exported,
465 },
466 );
467 }
468 for e in self.edges() {
469 idx.edges.push(graph_index::IndexEdge {
472 from: e.from,
473 to: e.to,
474 kind: e.kind,
475 weight: e.weight as f32,
476 });
477 }
478 idx.rebuild_interner();
479 idx
480 }
481
482 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
483 graph_index::ProjectIndex::index_dir(project_root)
484 }
485
486 pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
489 match self {
490 GraphProvider::PropertyGraph(g) => {
491 g.related_files(file_path, limit).unwrap_or_default()
492 }
493 GraphProvider::GraphIndex(_) => {
494 let mut result: Vec<(String, f64)> = Vec::new();
495 for dep in self.dependencies(file_path) {
496 result.push((dep, 1.0));
497 }
498 for dep in self.dependents(file_path) {
499 if !result.iter().any(|(p, _)| *p == dep) {
500 result.push((dep, 0.5));
501 }
502 }
503 result.truncate(limit);
504 result
505 }
506 }
507 }
508}
509
510fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
518 let t0 = std::time::Instant::now();
519
520 let mut pg_provider = None;
521 let mut pg_populated = false;
522 if let Ok(pg) = CodeGraph::open(project_root) {
523 let nodes = pg.node_count().unwrap_or(0);
524 let edges = pg.edge_count().unwrap_or(0);
525 let file_cat = pg.file_catalog_count().unwrap_or(0);
526 pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
527 if pg_populated {
528 log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
529 return (
530 Some(OpenGraphProvider {
531 source: GraphProviderSource::PropertyGraph,
532 provider: GraphProvider::PropertyGraph(pg),
533 }),
534 false,
535 );
536 }
537 if nodes > 0 && file_cat > 0 {
538 pg_provider = Some(pg);
539 }
540 }
541
542 let needs_build = !pg_populated;
544
545 if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
546 let files = idx.files.len();
547 let edges = idx.edges.len();
548 if !idx.edges.is_empty() || !idx.files.is_empty() {
549 log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
550 return (
551 Some(OpenGraphProvider {
552 source: GraphProviderSource::GraphIndex,
553 provider: GraphProvider::GraphIndex(idx),
554 }),
555 needs_build,
556 );
557 }
558 }
559
560 if let Some(pg) = pg_provider {
561 let nodes = pg.node_count().unwrap_or(0);
562 log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
563 return (
564 Some(OpenGraphProvider {
565 source: GraphProviderSource::PropertyGraph,
566 provider: GraphProvider::PropertyGraph(pg),
567 }),
568 needs_build,
569 );
570 }
571
572 (None, needs_build)
573}
574
575pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
581 let (existing, needs_build) = open_existing(project_root);
582 if needs_build {
583 trigger_lazy_graph_build(project_root);
584 }
585 existing
586}
587
588fn log_source_selection(
589 source: GraphProviderSource,
590 nodes: usize,
591 edges: usize,
592 start: std::time::Instant,
593) {
594 let elapsed_ms = start.elapsed().as_millis();
595 if std::env::var("LCTX_DEBUG").is_ok() {
596 eprintln!(
597 "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
598 );
599 }
600 let _ = (source, nodes, edges, elapsed_ms);
601}
602
603fn trigger_lazy_graph_build(project_root: &str) {
605 if cfg!(test) {
614 return;
615 }
616 if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
617 return;
618 }
619 let root = Path::new(project_root);
620 let is_project = crate::core::pathutil::has_project_marker(root)
623 || crate::core::pathutil::has_multi_repo_children(root);
624 if !is_project {
625 return;
626 }
627 super::index_orchestrator::ensure_all_background(project_root);
631}
632
633pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
644 let index = super::index_orchestrator::try_load_graph_index(project_root)
645 .filter(|i| !i.files.is_empty())
646 .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
647 super::property_graph::mirror_index(project_root, &index)
648}
649
650const BUILD_TIMEOUT_SECS: u64 = 30;
654
655static BUILD_GATE: std::sync::LazyLock<std::sync::Mutex<()>> =
660 std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
661
662pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
663 if let (Some(p), _) = open_existing(project_root) {
664 return Some(p);
665 }
666
667 let Ok(_gate) = BUILD_GATE.try_lock() else {
671 tracing::info!(
672 "open_or_build: another build in progress for {project_root}; returning None"
673 );
674 trigger_lazy_graph_build(project_root);
675 return None;
676 };
677
678 let root_owned = project_root.to_string();
681 let (tx, rx) = std::sync::mpsc::sync_channel(1);
682 std::thread::spawn(move || {
683 let idx = super::graph_index::load_or_build(&root_owned);
684 let _ = tx.send(idx);
685 });
686
687 let timeout = std::time::Duration::from_secs(BUILD_TIMEOUT_SECS);
688 match rx.recv_timeout(timeout) {
689 Ok(idx) if !idx.files.is_empty() => Some(OpenGraphProvider {
690 source: GraphProviderSource::GraphIndex,
691 provider: GraphProvider::GraphIndex(idx),
692 }),
693 Ok(_) => None,
694 Err(_) => {
695 tracing::warn!(
696 "open_or_build: graph build timed out after {BUILD_TIMEOUT_SECS}s for {project_root}; \
697 triggering background build"
698 );
699 trigger_lazy_graph_build(project_root);
700 None
701 }
702 }
703}
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn best_effort_prefers_graph_index_when_property_graph_empty() {
710 let _lock = crate::core::data_dir::test_env_lock();
711 let tmp = tempfile::tempdir().expect("tempdir");
712 let data = tmp.path().join("data");
713 std::fs::create_dir_all(&data).expect("mkdir data");
714 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
715
716 let project_root = tmp.path().join("proj");
717 std::fs::create_dir_all(&project_root).expect("mkdir proj");
718 let root = project_root.to_string_lossy().to_string();
719
720 let mut idx = ProjectIndex::new(&root);
721 idx.files.insert(
722 "src/main.rs".to_string(),
723 super::super::graph_index::FileEntry {
724 path: "src/main.rs".to_string(),
725 hash: "h".to_string(),
726 language: "rs".to_string(),
727 line_count: 1,
728 token_count: 1,
729 exports: vec![],
730 summary: String::new(),
731 },
732 );
733 idx.save().expect("save index");
734
735 let open = open_best_effort(&root).expect("open");
736 assert_eq!(open.source, GraphProviderSource::GraphIndex);
737
738 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
739 }
740
741 #[test]
742 fn best_effort_none_when_no_graphs() {
743 let _lock = crate::core::data_dir::test_env_lock();
744 let tmp = tempfile::tempdir().expect("tempdir");
745 let data = tmp.path().join("data");
746 std::fs::create_dir_all(&data).expect("mkdir data");
747 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
748
749 let project_root = tmp.path().join("proj");
750 std::fs::create_dir_all(&project_root).expect("mkdir proj");
751 let root = project_root.to_string_lossy().to_string();
752
753 let open = open_best_effort(&root);
754 assert!(open.is_none());
755
756 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
757 }
758
759 fn handle_provider() -> GraphProvider {
760 let mut idx = ProjectIndex::new("/test");
761 for (key, file, name, kind, start, end) in [
762 (
763 "src/lib.rs::Config",
764 "src/lib.rs",
765 "Config",
766 "struct",
767 5usize,
768 20usize,
769 ),
770 (
771 "src/lib.rs::Config::load",
772 "src/lib.rs",
773 "Config::load",
774 "method",
775 22,
776 35,
777 ),
778 ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
779 ] {
780 idx.symbols.insert(
781 key.to_string(),
782 graph_index::SymbolEntry {
783 file: file.to_string(),
784 name: name.to_string(),
785 kind: kind.to_string(),
786 start_line: start,
787 end_line: end,
788 is_exported: true,
789 },
790 );
791 }
792 GraphProvider::GraphIndex(idx)
793 }
794
795 #[test]
796 fn handle_resolves_exact_file_and_name() {
797 let gp = handle_provider();
798 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
799 let sym = gp.find_symbol_by_handle(&h).expect("resolves");
800 assert_eq!(sym.name, "Config::load");
801 assert_eq!(sym.start_line, 22);
802 }
803
804 #[test]
805 fn handle_resolves_after_line_drift() {
806 let gp = handle_provider();
809 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
810 let sym = gp
811 .find_symbol_by_handle(&h)
812 .expect("resolves despite drift");
813 assert_eq!(sym.start_line, 22);
814 }
815
816 #[test]
817 fn handle_resolves_by_unqualified_tail() {
818 let gp = handle_provider();
821 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
822 let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
823 assert_eq!(sym.name, "Config::load");
824 }
825
826 #[test]
827 fn handle_unknown_file_returns_none() {
828 let gp = handle_provider();
829 let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
830 assert!(gp.find_symbol_by_handle(&h).is_none());
831 }
832
833 #[test]
834 fn pg_kind_filter_matches_precise_metadata_kind() {
835 use super::super::property_graph::{Node, NodeKind};
839
840 let pg = CodeGraph::open_in_memory().unwrap();
841 pg.upsert_node(
842 &Node::symbol("State", "simplex/simplex.go", NodeKind::Symbol)
843 .with_lines(129, 140)
844 .with_metadata(r#"{"kind":"struct","exported":true}"#),
845 )
846 .unwrap();
847 let gp = GraphProvider::PropertyGraph(pg);
848
849 let hits = gp.find_symbols("State", None, Some("struct"));
850 assert_eq!(
851 hits.len(),
852 1,
853 "kind=struct must resolve the exported struct"
854 );
855 assert_eq!(hits[0].kind, "struct");
856 assert!(hits[0].is_exported);
857
858 assert!(gp.find_symbols("State", None, Some("fn")).is_empty());
860 }
861
862 #[test]
863 fn parity_dependencies_both_stores_agree() {
864 use super::super::graph_index::{FileEntry, IndexEdge};
865 use super::super::property_graph::{Edge, EdgeKind, Node};
866
867 let pg = CodeGraph::open_in_memory().unwrap();
868 let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
869 let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
870 let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
871 pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
872 .unwrap();
873 pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
874 .unwrap();
875
876 let mut idx = ProjectIndex::new("/test");
877 for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
878 idx.files.insert(
879 name.to_string(),
880 FileEntry {
881 path: name.to_string(),
882 hash: "h".into(),
883 language: "rs".into(),
884 line_count: 1,
885 token_count: 1,
886 exports: vec![],
887 summary: String::new(),
888 },
889 );
890 }
891 idx.edges.push(IndexEdge {
892 from: "src/a.rs".into(),
893 to: "src/b.rs".into(),
894 kind: "import".into(),
895 weight: 1.0,
896 });
897 idx.edges.push(IndexEdge {
898 from: "src/a.rs".into(),
899 to: "src/c.rs".into(),
900 kind: "import".into(),
901 weight: 1.0,
902 });
903
904 let pg_deps = GraphProvider::PropertyGraph(pg);
905 let gi_deps = GraphProvider::GraphIndex(idx);
906
907 let mut pg_result = pg_deps.dependencies("src/a.rs");
908 let mut gi_result = gi_deps.dependencies("src/a.rs");
909 pg_result.sort();
910 gi_result.sort();
911
912 assert_eq!(
913 pg_result, gi_result,
914 "Import edges must match between PG and GraphIndex"
915 );
916
917 let mut pg_dependents = pg_deps.dependents("src/b.rs");
918 let mut gi_dependents = gi_deps.dependents("src/b.rs");
919 pg_dependents.sort();
920 gi_dependents.sort();
921 assert_eq!(
922 pg_dependents, gi_dependents,
923 "Dependents must match between PG and GraphIndex"
924 );
925 }
926
927 #[test]
934 fn materialize_project_index_round_trips_losslessly() {
935 use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
936 use super::super::property_graph::populate_from_project_index;
937
938 let mut a = ProjectIndex::new("/test");
939 a.files.insert(
940 "src/a.rs".to_string(),
941 FileEntry {
942 path: "src/a.rs".to_string(),
943 hash: "hash-a".to_string(),
944 language: "rs".to_string(),
945 line_count: 42,
946 token_count: 137,
947 exports: vec!["Foo".to_string()],
948 summary: "module a".to_string(),
949 },
950 );
951 a.files.insert(
952 "src/b.rs".to_string(),
953 FileEntry {
954 path: "src/b.rs".to_string(),
955 hash: "hash-b".to_string(),
956 language: "rs".to_string(),
957 line_count: 7,
958 token_count: 19,
959 exports: vec![],
960 summary: String::new(),
961 },
962 );
963 a.symbols.insert(
966 "src/a.rs::Foo".to_string(),
967 SymbolEntry {
968 file: "src/a.rs".to_string(),
969 name: "Foo".to_string(),
970 kind: "struct".to_string(),
971 start_line: 1,
972 end_line: 9,
973 is_exported: true,
974 },
975 );
976 a.symbols.insert(
977 "src/b.rs::helper".to_string(),
978 SymbolEntry {
979 file: "src/b.rs".to_string(),
980 name: "helper".to_string(),
981 kind: "function".to_string(),
982 start_line: 3,
983 end_line: 6,
984 is_exported: false,
985 },
986 );
987 a.edges.push(IndexEdge {
988 from: "src/b.rs".to_string(),
989 to: "src/a.rs".to_string(),
990 kind: "import".to_string(),
991 weight: 1.0,
992 });
993
994 let pg = CodeGraph::open_in_memory().unwrap();
995 populate_from_project_index(&pg, &a).unwrap();
996 let provider = GraphProvider::PropertyGraph(pg);
997 let b = provider.materialize_project_index("/test");
998
999 let mut a_files: Vec<&String> = a.files.keys().collect();
1001 let mut b_files: Vec<&String> = b.files.keys().collect();
1002 a_files.sort();
1003 b_files.sort();
1004 assert_eq!(a_files, b_files, "file inventory must round-trip");
1005 for (path, fa) in &a.files {
1006 let fb = b.files.get(path).expect("file present after round trip");
1007 assert_eq!(fa.hash, fb.hash, "hash {path}");
1008 assert_eq!(fa.language, fb.language, "language {path}");
1009 assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
1010 assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
1011 assert_eq!(fa.exports, fb.exports, "exports {path}");
1012 assert_eq!(fa.summary, fb.summary, "summary {path}");
1013 }
1014
1015 let mut a_syms: Vec<&String> = a.symbols.keys().collect();
1017 let mut b_syms: Vec<&String> = b.symbols.keys().collect();
1018 a_syms.sort();
1019 b_syms.sort();
1020 assert_eq!(a_syms, b_syms, "symbol table must round-trip");
1021 for (key, sa) in &a.symbols {
1022 let sb = b.symbols.get(key).expect("symbol present after round trip");
1023 assert_eq!(sa.name, sb.name, "name {key}");
1024 assert_eq!(sa.file, sb.file, "file {key}");
1025 assert_eq!(sa.kind, sb.kind, "kind {key}");
1026 assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
1027 assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
1028 assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
1029 }
1030
1031 assert!(
1033 b.edges
1034 .iter()
1035 .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
1036 "import edge must round-trip; got {:?}",
1037 b.edges
1038 );
1039 }
1040}