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, kind_filter)
179 .unwrap_or_default()
180 .into_iter()
181 .map(symbol_info_from_node)
182 .collect(),
183 GraphProvider::GraphIndex(i) => {
184 let name_lower = name.to_lowercase();
185 i.symbols
186 .values()
187 .filter(|s| s.name.to_lowercase().contains(&name_lower))
188 .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
189 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
190 .take(100)
191 .map(|s| SymbolInfo {
192 name: s.name.clone(),
193 file: s.file.clone(),
194 kind: s.kind.clone(),
195 start_line: s.start_line,
196 end_line: s.end_line,
197 is_exported: s.is_exported,
198 })
199 .collect()
200 }
201 }
202 }
203
204 pub fn all_symbols(&self) -> Vec<SymbolInfo> {
208 match self {
209 GraphProvider::PropertyGraph(g) => g
210 .all_symbols()
211 .unwrap_or_default()
212 .into_iter()
213 .map(symbol_info_from_node)
214 .collect(),
215 GraphProvider::GraphIndex(i) => i
216 .symbols
217 .values()
218 .map(|s| SymbolInfo {
219 name: s.name.clone(),
220 file: s.file.clone(),
221 kind: s.kind.clone(),
222 start_line: s.start_line,
223 end_line: s.end_line,
224 is_exported: s.is_exported,
225 })
226 .collect(),
227 }
228 }
229
230 pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
231 match self {
232 GraphProvider::PropertyGraph(g) => {
233 let parts: Vec<&str> = key.splitn(2, "::").collect();
238 if parts.len() != 2 {
239 return None;
240 }
241 let (file_path, sym_name) = (parts[0], parts[1]);
242 g.get_node_by_symbol(sym_name, file_path)
243 .ok()
244 .flatten()
245 .map(symbol_info_from_node)
246 }
247 GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
248 name: s.name.clone(),
249 file: s.file.clone(),
250 kind: s.kind.clone(),
251 start_line: s.start_line,
252 end_line: s.end_line,
253 is_exported: s.is_exported,
254 }),
255 }
256 }
257
258 pub fn find_symbol_by_handle(
273 &self,
274 handle: &crate::core::handle::SymbolHandle,
275 ) -> Option<SymbolInfo> {
276 let key = format!("{}::{}", handle.path, handle.name);
277 if let Some(sym) = self.get_symbol(&key) {
278 return Some(sym);
279 }
280
281 let tail = handle
282 .name
283 .rsplit("::")
284 .next()
285 .unwrap_or(handle.name.as_str());
286 let mut candidates: Vec<SymbolInfo> = self
287 .all_symbols()
288 .into_iter()
289 .filter(|s| s.file == handle.path)
290 .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
291 .collect();
292 if candidates.is_empty() {
293 return None;
294 }
295 candidates.sort_by(|a, b| {
296 let exact_a = u8::from(a.name != handle.name);
297 let exact_b = u8::from(b.name != handle.name);
298 exact_a
299 .cmp(&exact_b)
300 .then_with(|| {
301 line_distance(a.start_line, handle.line)
302 .cmp(&line_distance(b.start_line, handle.line))
303 })
304 .then_with(|| a.start_line.cmp(&b.start_line))
305 .then_with(|| a.name.cmp(&b.name))
306 });
307 candidates.into_iter().next()
308 }
309
310 pub fn edges(&self) -> Vec<EdgeInfo> {
311 match self {
312 GraphProvider::PropertyGraph(g) => g
318 .all_edges_flat()
319 .unwrap_or_default()
320 .into_iter()
321 .map(|(from, to, kind, weight)| EdgeInfo {
322 from,
323 to,
324 kind: index_edge_kind(&kind),
325 weight,
326 })
327 .collect(),
328 GraphProvider::GraphIndex(i) => i
329 .edges
330 .iter()
331 .map(|e| EdgeInfo {
332 from: e.from.clone(),
333 to: e.to.clone(),
334 kind: e.kind.clone(),
335 weight: e.weight as f64,
336 })
337 .collect(),
338 }
339 }
340
341 pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
342 self.edges()
343 .into_iter()
344 .filter(|e| e.kind == kind)
345 .collect()
346 }
347
348 pub fn file_entries(&self) -> Vec<FileInfo> {
352 match self {
353 GraphProvider::PropertyGraph(_) => self
354 .file_paths()
355 .into_iter()
356 .filter_map(|p| self.get_file_entry(&p))
357 .collect(),
358 GraphProvider::GraphIndex(i) => i
359 .files
360 .values()
361 .map(|e| FileInfo {
362 path: e.path.clone(),
363 hash: e.hash.clone(),
364 language: e.language.clone(),
365 line_count: e.line_count,
366 token_count: e.token_count,
367 exports: e.exports.clone(),
368 summary: e.summary.clone(),
369 })
370 .collect(),
371 }
372 }
373
374 pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
375 match self {
376 GraphProvider::PropertyGraph(g) => {
377 g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
378 path: e.path,
379 hash: e.hash,
380 language: e.language,
381 line_count: e.line_count,
382 token_count: e.token_count,
383 exports: e.exports,
384 summary: e.summary,
385 })
386 }
387 GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
388 path: e.path.clone(),
389 hash: e.hash.clone(),
390 language: e.language.clone(),
391 line_count: e.line_count,
392 token_count: e.token_count,
393 exports: e.exports.clone(),
394 summary: e.summary.clone(),
395 }),
396 }
397 }
398
399 pub fn last_scan(&self) -> String {
400 match self {
401 GraphProvider::PropertyGraph(_) => String::new(),
402 GraphProvider::GraphIndex(i) => i.last_scan.clone(),
403 }
404 }
405
406 pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
415 if let GraphProvider::GraphIndex(i) = self {
416 return i.clone();
417 }
418 let mut idx = ProjectIndex::new(project_root);
419 if let Some(meta) = super::property_graph::load_meta(project_root)
424 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
425 {
426 idx.last_scan = dt
427 .with_timezone(&chrono::Local)
428 .format("%Y-%m-%d %H:%M:%S")
429 .to_string();
430 }
431 for f in self.file_entries() {
432 idx.files.insert(
433 f.path.clone(),
434 graph_index::FileEntry {
435 path: f.path,
436 hash: f.hash,
437 language: f.language,
438 line_count: f.line_count,
439 token_count: f.token_count,
440 exports: f.exports,
441 summary: f.summary,
442 },
443 );
444 }
445 for s in self.all_symbols() {
446 let key = format!("{}::{}", s.file, s.name);
447 idx.symbols.insert(
448 key,
449 graph_index::SymbolEntry {
450 file: s.file,
451 name: s.name,
452 kind: s.kind,
453 start_line: s.start_line,
454 end_line: s.end_line,
455 is_exported: s.is_exported,
456 },
457 );
458 }
459 for e in self.edges() {
460 idx.edges.push(graph_index::IndexEdge {
463 from: e.from,
464 to: e.to,
465 kind: e.kind,
466 weight: e.weight as f32,
467 });
468 }
469 idx
470 }
471
472 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
473 graph_index::ProjectIndex::index_dir(project_root)
474 }
475
476 pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
479 match self {
480 GraphProvider::PropertyGraph(g) => {
481 g.related_files(file_path, limit).unwrap_or_default()
482 }
483 GraphProvider::GraphIndex(_) => {
484 let mut result: Vec<(String, f64)> = Vec::new();
485 for dep in self.dependencies(file_path) {
486 result.push((dep, 1.0));
487 }
488 for dep in self.dependents(file_path) {
489 if !result.iter().any(|(p, _)| *p == dep) {
490 result.push((dep, 0.5));
491 }
492 }
493 result.truncate(limit);
494 result
495 }
496 }
497 }
498}
499
500fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
508 let t0 = std::time::Instant::now();
509
510 let mut pg_provider = None;
511 let mut pg_populated = false;
512 if let Ok(pg) = CodeGraph::open(project_root) {
513 let nodes = pg.node_count().unwrap_or(0);
514 let edges = pg.edge_count().unwrap_or(0);
515 let file_cat = pg.file_catalog_count().unwrap_or(0);
516 pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
517 if pg_populated {
518 log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
519 return (
520 Some(OpenGraphProvider {
521 source: GraphProviderSource::PropertyGraph,
522 provider: GraphProvider::PropertyGraph(pg),
523 }),
524 false,
525 );
526 }
527 if nodes > 0 && file_cat > 0 {
528 pg_provider = Some(pg);
529 }
530 }
531
532 let needs_build = !pg_populated;
534
535 if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
536 let files = idx.files.len();
537 let edges = idx.edges.len();
538 if !idx.edges.is_empty() || !idx.files.is_empty() {
539 log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
540 return (
541 Some(OpenGraphProvider {
542 source: GraphProviderSource::GraphIndex,
543 provider: GraphProvider::GraphIndex(idx),
544 }),
545 needs_build,
546 );
547 }
548 }
549
550 if let Some(pg) = pg_provider {
551 let nodes = pg.node_count().unwrap_or(0);
552 log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
553 return (
554 Some(OpenGraphProvider {
555 source: GraphProviderSource::PropertyGraph,
556 provider: GraphProvider::PropertyGraph(pg),
557 }),
558 needs_build,
559 );
560 }
561
562 (None, needs_build)
563}
564
565pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
571 let (existing, needs_build) = open_existing(project_root);
572 if needs_build {
573 trigger_lazy_graph_build(project_root);
574 }
575 existing
576}
577
578fn log_source_selection(
579 source: GraphProviderSource,
580 nodes: usize,
581 edges: usize,
582 start: std::time::Instant,
583) {
584 let elapsed_ms = start.elapsed().as_millis();
585 if std::env::var("LCTX_DEBUG").is_ok() {
586 eprintln!(
587 "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
588 );
589 }
590 let _ = (source, nodes, edges, elapsed_ms);
591}
592
593fn trigger_lazy_graph_build(project_root: &str) {
595 if cfg!(test) {
604 return;
605 }
606 if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
607 return;
608 }
609 let root = Path::new(project_root);
610 let is_project = crate::core::pathutil::has_project_marker(root)
613 || crate::core::pathutil::has_multi_repo_children(root);
614 if !is_project {
615 return;
616 }
617 super::index_orchestrator::ensure_all_background(project_root);
621}
622
623pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
634 let index = super::index_orchestrator::try_load_graph_index(project_root)
635 .filter(|i| !i.files.is_empty())
636 .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
637 super::property_graph::mirror_index(project_root, &index)
638}
639
640pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
641 if let (Some(p), _) = open_existing(project_root) {
647 return Some(p);
648 }
649 let idx = super::graph_index::load_or_build(project_root);
650 if idx.files.is_empty() {
651 return None;
652 }
653 Some(OpenGraphProvider {
654 source: GraphProviderSource::GraphIndex,
655 provider: GraphProvider::GraphIndex(idx),
656 })
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn best_effort_prefers_graph_index_when_property_graph_empty() {
665 let _lock = crate::core::data_dir::test_env_lock();
666 let tmp = tempfile::tempdir().expect("tempdir");
667 let data = tmp.path().join("data");
668 std::fs::create_dir_all(&data).expect("mkdir data");
669 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
670
671 let project_root = tmp.path().join("proj");
672 std::fs::create_dir_all(&project_root).expect("mkdir proj");
673 let root = project_root.to_string_lossy().to_string();
674
675 let mut idx = ProjectIndex::new(&root);
676 idx.files.insert(
677 "src/main.rs".to_string(),
678 super::super::graph_index::FileEntry {
679 path: "src/main.rs".to_string(),
680 hash: "h".to_string(),
681 language: "rs".to_string(),
682 line_count: 1,
683 token_count: 1,
684 exports: vec![],
685 summary: String::new(),
686 },
687 );
688 idx.save().expect("save index");
689
690 let open = open_best_effort(&root).expect("open");
691 assert_eq!(open.source, GraphProviderSource::GraphIndex);
692
693 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
694 }
695
696 #[test]
697 fn best_effort_none_when_no_graphs() {
698 let _lock = crate::core::data_dir::test_env_lock();
699 let tmp = tempfile::tempdir().expect("tempdir");
700 let data = tmp.path().join("data");
701 std::fs::create_dir_all(&data).expect("mkdir data");
702 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
703
704 let project_root = tmp.path().join("proj");
705 std::fs::create_dir_all(&project_root).expect("mkdir proj");
706 let root = project_root.to_string_lossy().to_string();
707
708 let open = open_best_effort(&root);
709 assert!(open.is_none());
710
711 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
712 }
713
714 fn handle_provider() -> GraphProvider {
715 let mut idx = ProjectIndex::new("/test");
716 for (key, file, name, kind, start, end) in [
717 (
718 "src/lib.rs::Config",
719 "src/lib.rs",
720 "Config",
721 "struct",
722 5usize,
723 20usize,
724 ),
725 (
726 "src/lib.rs::Config::load",
727 "src/lib.rs",
728 "Config::load",
729 "method",
730 22,
731 35,
732 ),
733 ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
734 ] {
735 idx.symbols.insert(
736 key.to_string(),
737 graph_index::SymbolEntry {
738 file: file.to_string(),
739 name: name.to_string(),
740 kind: kind.to_string(),
741 start_line: start,
742 end_line: end,
743 is_exported: true,
744 },
745 );
746 }
747 GraphProvider::GraphIndex(idx)
748 }
749
750 #[test]
751 fn handle_resolves_exact_file_and_name() {
752 let gp = handle_provider();
753 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
754 let sym = gp.find_symbol_by_handle(&h).expect("resolves");
755 assert_eq!(sym.name, "Config::load");
756 assert_eq!(sym.start_line, 22);
757 }
758
759 #[test]
760 fn handle_resolves_after_line_drift() {
761 let gp = handle_provider();
764 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
765 let sym = gp
766 .find_symbol_by_handle(&h)
767 .expect("resolves despite drift");
768 assert_eq!(sym.start_line, 22);
769 }
770
771 #[test]
772 fn handle_resolves_by_unqualified_tail() {
773 let gp = handle_provider();
776 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
777 let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
778 assert_eq!(sym.name, "Config::load");
779 }
780
781 #[test]
782 fn handle_unknown_file_returns_none() {
783 let gp = handle_provider();
784 let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
785 assert!(gp.find_symbol_by_handle(&h).is_none());
786 }
787
788 #[test]
789 fn parity_dependencies_both_stores_agree() {
790 use super::super::graph_index::{FileEntry, IndexEdge};
791 use super::super::property_graph::{Edge, EdgeKind, Node};
792
793 let pg = CodeGraph::open_in_memory().unwrap();
794 let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
795 let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
796 let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
797 pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
798 .unwrap();
799 pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
800 .unwrap();
801
802 let mut idx = ProjectIndex::new("/test");
803 for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
804 idx.files.insert(
805 name.to_string(),
806 FileEntry {
807 path: name.to_string(),
808 hash: "h".into(),
809 language: "rs".into(),
810 line_count: 1,
811 token_count: 1,
812 exports: vec![],
813 summary: String::new(),
814 },
815 );
816 }
817 idx.edges.push(IndexEdge {
818 from: "src/a.rs".into(),
819 to: "src/b.rs".into(),
820 kind: "import".into(),
821 weight: 1.0,
822 });
823 idx.edges.push(IndexEdge {
824 from: "src/a.rs".into(),
825 to: "src/c.rs".into(),
826 kind: "import".into(),
827 weight: 1.0,
828 });
829
830 let pg_deps = GraphProvider::PropertyGraph(pg);
831 let gi_deps = GraphProvider::GraphIndex(idx);
832
833 let mut pg_result = pg_deps.dependencies("src/a.rs");
834 let mut gi_result = gi_deps.dependencies("src/a.rs");
835 pg_result.sort();
836 gi_result.sort();
837
838 assert_eq!(
839 pg_result, gi_result,
840 "Import edges must match between PG and GraphIndex"
841 );
842
843 let mut pg_dependents = pg_deps.dependents("src/b.rs");
844 let mut gi_dependents = gi_deps.dependents("src/b.rs");
845 pg_dependents.sort();
846 gi_dependents.sort();
847 assert_eq!(
848 pg_dependents, gi_dependents,
849 "Dependents must match between PG and GraphIndex"
850 );
851 }
852
853 #[test]
860 fn materialize_project_index_round_trips_losslessly() {
861 use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
862 use super::super::property_graph::populate_from_project_index;
863
864 let mut a = ProjectIndex::new("/test");
865 a.files.insert(
866 "src/a.rs".to_string(),
867 FileEntry {
868 path: "src/a.rs".to_string(),
869 hash: "hash-a".to_string(),
870 language: "rs".to_string(),
871 line_count: 42,
872 token_count: 137,
873 exports: vec!["Foo".to_string()],
874 summary: "module a".to_string(),
875 },
876 );
877 a.files.insert(
878 "src/b.rs".to_string(),
879 FileEntry {
880 path: "src/b.rs".to_string(),
881 hash: "hash-b".to_string(),
882 language: "rs".to_string(),
883 line_count: 7,
884 token_count: 19,
885 exports: vec![],
886 summary: String::new(),
887 },
888 );
889 a.symbols.insert(
892 "src/a.rs::Foo".to_string(),
893 SymbolEntry {
894 file: "src/a.rs".to_string(),
895 name: "Foo".to_string(),
896 kind: "struct".to_string(),
897 start_line: 1,
898 end_line: 9,
899 is_exported: true,
900 },
901 );
902 a.symbols.insert(
903 "src/b.rs::helper".to_string(),
904 SymbolEntry {
905 file: "src/b.rs".to_string(),
906 name: "helper".to_string(),
907 kind: "function".to_string(),
908 start_line: 3,
909 end_line: 6,
910 is_exported: false,
911 },
912 );
913 a.edges.push(IndexEdge {
914 from: "src/b.rs".to_string(),
915 to: "src/a.rs".to_string(),
916 kind: "import".to_string(),
917 weight: 1.0,
918 });
919
920 let pg = CodeGraph::open_in_memory().unwrap();
921 populate_from_project_index(&pg, &a).unwrap();
922 let provider = GraphProvider::PropertyGraph(pg);
923 let b = provider.materialize_project_index("/test");
924
925 let mut a_files: Vec<&String> = a.files.keys().collect();
927 let mut b_files: Vec<&String> = b.files.keys().collect();
928 a_files.sort();
929 b_files.sort();
930 assert_eq!(a_files, b_files, "file inventory must round-trip");
931 for (path, fa) in &a.files {
932 let fb = b.files.get(path).expect("file present after round trip");
933 assert_eq!(fa.hash, fb.hash, "hash {path}");
934 assert_eq!(fa.language, fb.language, "language {path}");
935 assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
936 assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
937 assert_eq!(fa.exports, fb.exports, "exports {path}");
938 assert_eq!(fa.summary, fb.summary, "summary {path}");
939 }
940
941 let mut a_syms: Vec<&String> = a.symbols.keys().collect();
943 let mut b_syms: Vec<&String> = b.symbols.keys().collect();
944 a_syms.sort();
945 b_syms.sort();
946 assert_eq!(a_syms, b_syms, "symbol table must round-trip");
947 for (key, sa) in &a.symbols {
948 let sb = b.symbols.get(key).expect("symbol present after round trip");
949 assert_eq!(sa.name, sb.name, "name {key}");
950 assert_eq!(sa.file, sb.file, "file {key}");
951 assert_eq!(sa.kind, sb.kind, "kind {key}");
952 assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
953 assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
954 assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
955 }
956
957 assert!(
959 b.edges
960 .iter()
961 .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
962 "import edge must round-trip; got {:?}",
963 b.edges
964 );
965 }
966}