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 symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
39 let (meta_kind, meta_exported) =
40 super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
41 SymbolInfo {
42 kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
43 is_exported: meta_exported.unwrap_or(true),
44 name: n.name,
45 file: n.file_path,
46 start_line: n.line_start.unwrap_or(0),
47 end_line: n.line_end.unwrap_or(0),
48 }
49}
50
51#[derive(Debug, Clone)]
52pub struct EdgeInfo {
53 pub from: String,
54 pub to: String,
55 pub kind: String,
56 pub weight: f64,
57}
58
59#[derive(Debug, Clone)]
60pub struct FileInfo {
61 pub path: String,
62 pub hash: String,
63 pub language: String,
64 pub line_count: usize,
65 pub token_count: usize,
66 pub exports: Vec<String>,
67 pub summary: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum GraphProviderSource {
72 PropertyGraph,
73 GraphIndex,
74}
75
76pub enum GraphProvider {
77 PropertyGraph(CodeGraph),
78 GraphIndex(ProjectIndex),
79}
80
81pub struct OpenGraphProvider {
82 pub source: GraphProviderSource,
83 pub provider: GraphProvider,
84}
85
86impl GraphProvider {
87 pub fn node_count(&self) -> Option<usize> {
88 match self {
89 GraphProvider::PropertyGraph(g) => g.node_count().ok(),
90 GraphProvider::GraphIndex(i) => Some(i.file_count()),
91 }
92 }
93
94 pub fn edge_count(&self) -> Option<usize> {
95 match self {
96 GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
97 GraphProvider::GraphIndex(i) => Some(i.edge_count()),
98 }
99 }
100
101 pub fn dependencies(&self, file_path: &str) -> Vec<String> {
102 match self {
103 GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
104 GraphProvider::GraphIndex(i) => i
105 .edges
106 .iter()
107 .filter(|e| e.kind == "import" && e.from == file_path)
108 .map(|e| e.to.clone())
109 .collect(),
110 }
111 }
112
113 pub fn dependents(&self, file_path: &str) -> Vec<String> {
114 match self {
115 GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
116 GraphProvider::GraphIndex(i) => i
117 .edges
118 .iter()
119 .filter(|e| e.kind == "import" && e.to == file_path)
120 .map(|e| e.from.clone())
121 .collect(),
122 }
123 }
124
125 pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
126 match self {
127 GraphProvider::PropertyGraph(g) => g
128 .impact_analysis(file_path, depth)
129 .map(|r| r.affected_files)
130 .unwrap_or_default(),
131 GraphProvider::GraphIndex(i) => i.get_related(file_path, depth),
132 }
133 }
134
135 pub fn file_paths(&self) -> Vec<String> {
136 match self {
137 GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
138 GraphProvider::GraphIndex(i) => {
139 let mut paths: Vec<String> = i.files.keys().cloned().collect();
140 paths.sort();
141 paths
142 }
143 }
144 }
145
146 pub fn file_count(&self) -> usize {
147 match self {
148 GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
149 GraphProvider::GraphIndex(i) => i.files.len(),
150 }
151 }
152
153 pub fn symbol_count(&self) -> usize {
154 match self {
155 GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
156 GraphProvider::GraphIndex(i) => i.symbols.len(),
157 }
158 }
159
160 pub fn find_symbols(
161 &self,
162 name: &str,
163 file_filter: Option<&str>,
164 kind_filter: Option<&str>,
165 ) -> Vec<SymbolInfo> {
166 match self {
167 GraphProvider::PropertyGraph(g) => g
168 .find_symbols(name, file_filter, kind_filter)
169 .unwrap_or_default()
170 .into_iter()
171 .map(symbol_info_from_node)
172 .collect(),
173 GraphProvider::GraphIndex(i) => {
174 let name_lower = name.to_lowercase();
175 i.symbols
176 .values()
177 .filter(|s| s.name.to_lowercase().contains(&name_lower))
178 .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
179 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
180 .take(100)
181 .map(|s| SymbolInfo {
182 name: s.name.clone(),
183 file: s.file.clone(),
184 kind: s.kind.clone(),
185 start_line: s.start_line,
186 end_line: s.end_line,
187 is_exported: s.is_exported,
188 })
189 .collect()
190 }
191 }
192 }
193
194 pub fn all_symbols(&self) -> Vec<SymbolInfo> {
198 match self {
199 GraphProvider::PropertyGraph(g) => g
200 .all_symbols()
201 .unwrap_or_default()
202 .into_iter()
203 .map(symbol_info_from_node)
204 .collect(),
205 GraphProvider::GraphIndex(i) => i
206 .symbols
207 .values()
208 .map(|s| SymbolInfo {
209 name: s.name.clone(),
210 file: s.file.clone(),
211 kind: s.kind.clone(),
212 start_line: s.start_line,
213 end_line: s.end_line,
214 is_exported: s.is_exported,
215 })
216 .collect(),
217 }
218 }
219
220 pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
221 match self {
222 GraphProvider::PropertyGraph(g) => {
223 let parts: Vec<&str> = key.splitn(2, "::").collect();
228 if parts.len() != 2 {
229 return None;
230 }
231 let (file_path, sym_name) = (parts[0], parts[1]);
232 g.get_node_by_symbol(sym_name, file_path)
233 .ok()
234 .flatten()
235 .map(symbol_info_from_node)
236 }
237 GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
238 name: s.name.clone(),
239 file: s.file.clone(),
240 kind: s.kind.clone(),
241 start_line: s.start_line,
242 end_line: s.end_line,
243 is_exported: s.is_exported,
244 }),
245 }
246 }
247
248 pub fn edges(&self) -> Vec<EdgeInfo> {
249 match self {
250 GraphProvider::PropertyGraph(g) => g
256 .all_edges_flat()
257 .unwrap_or_default()
258 .into_iter()
259 .map(|(from, to, kind, weight)| EdgeInfo {
260 from,
261 to,
262 kind: index_edge_kind(&kind),
263 weight,
264 })
265 .collect(),
266 GraphProvider::GraphIndex(i) => i
267 .edges
268 .iter()
269 .map(|e| EdgeInfo {
270 from: e.from.clone(),
271 to: e.to.clone(),
272 kind: e.kind.clone(),
273 weight: e.weight as f64,
274 })
275 .collect(),
276 }
277 }
278
279 pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
280 self.edges()
281 .into_iter()
282 .filter(|e| e.kind == kind)
283 .collect()
284 }
285
286 pub fn file_entries(&self) -> Vec<FileInfo> {
290 match self {
291 GraphProvider::PropertyGraph(_) => self
292 .file_paths()
293 .into_iter()
294 .filter_map(|p| self.get_file_entry(&p))
295 .collect(),
296 GraphProvider::GraphIndex(i) => i
297 .files
298 .values()
299 .map(|e| FileInfo {
300 path: e.path.clone(),
301 hash: e.hash.clone(),
302 language: e.language.clone(),
303 line_count: e.line_count,
304 token_count: e.token_count,
305 exports: e.exports.clone(),
306 summary: e.summary.clone(),
307 })
308 .collect(),
309 }
310 }
311
312 pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
313 match self {
314 GraphProvider::PropertyGraph(g) => {
315 g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
316 path: e.path,
317 hash: e.hash,
318 language: e.language,
319 line_count: e.line_count,
320 token_count: e.token_count,
321 exports: e.exports,
322 summary: e.summary,
323 })
324 }
325 GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
326 path: e.path.clone(),
327 hash: e.hash.clone(),
328 language: e.language.clone(),
329 line_count: e.line_count,
330 token_count: e.token_count,
331 exports: e.exports.clone(),
332 summary: e.summary.clone(),
333 }),
334 }
335 }
336
337 pub fn last_scan(&self) -> String {
338 match self {
339 GraphProvider::PropertyGraph(_) => String::new(),
340 GraphProvider::GraphIndex(i) => i.last_scan.clone(),
341 }
342 }
343
344 pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
353 if let GraphProvider::GraphIndex(i) = self {
354 return i.clone();
355 }
356 let mut idx = ProjectIndex::new(project_root);
357 if let Some(meta) = super::property_graph::load_meta(project_root)
362 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
363 {
364 idx.last_scan = dt
365 .with_timezone(&chrono::Local)
366 .format("%Y-%m-%d %H:%M:%S")
367 .to_string();
368 }
369 for f in self.file_entries() {
370 idx.files.insert(
371 f.path.clone(),
372 graph_index::FileEntry {
373 path: f.path,
374 hash: f.hash,
375 language: f.language,
376 line_count: f.line_count,
377 token_count: f.token_count,
378 exports: f.exports,
379 summary: f.summary,
380 },
381 );
382 }
383 for s in self.all_symbols() {
384 let key = format!("{}::{}", s.file, s.name);
385 idx.symbols.insert(
386 key,
387 graph_index::SymbolEntry {
388 file: s.file,
389 name: s.name,
390 kind: s.kind,
391 start_line: s.start_line,
392 end_line: s.end_line,
393 is_exported: s.is_exported,
394 },
395 );
396 }
397 for e in self.edges() {
398 idx.edges.push(graph_index::IndexEdge {
401 from: e.from,
402 to: e.to,
403 kind: e.kind,
404 weight: e.weight as f32,
405 });
406 }
407 idx
408 }
409
410 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
411 graph_index::ProjectIndex::index_dir(project_root)
412 }
413
414 pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
417 match self {
418 GraphProvider::PropertyGraph(g) => {
419 g.related_files(file_path, limit).unwrap_or_default()
420 }
421 GraphProvider::GraphIndex(_) => {
422 let mut result: Vec<(String, f64)> = Vec::new();
423 for dep in self.dependencies(file_path) {
424 result.push((dep, 1.0));
425 }
426 for dep in self.dependents(file_path) {
427 if !result.iter().any(|(p, _)| *p == dep) {
428 result.push((dep, 0.5));
429 }
430 }
431 result.truncate(limit);
432 result
433 }
434 }
435 }
436}
437
438fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
446 let t0 = std::time::Instant::now();
447
448 let mut pg_provider = None;
449 let mut pg_populated = false;
450 if let Ok(pg) = CodeGraph::open(project_root) {
451 let nodes = pg.node_count().unwrap_or(0);
452 let edges = pg.edge_count().unwrap_or(0);
453 let file_cat = pg.file_catalog_count().unwrap_or(0);
454 pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
455 if pg_populated {
456 log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
457 return (
458 Some(OpenGraphProvider {
459 source: GraphProviderSource::PropertyGraph,
460 provider: GraphProvider::PropertyGraph(pg),
461 }),
462 false,
463 );
464 }
465 if nodes > 0 && file_cat > 0 {
466 pg_provider = Some(pg);
467 }
468 }
469
470 let needs_build = !pg_populated;
472
473 if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
474 let files = idx.files.len();
475 let edges = idx.edges.len();
476 if !idx.edges.is_empty() || !idx.files.is_empty() {
477 log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
478 return (
479 Some(OpenGraphProvider {
480 source: GraphProviderSource::GraphIndex,
481 provider: GraphProvider::GraphIndex(idx),
482 }),
483 needs_build,
484 );
485 }
486 }
487
488 if let Some(pg) = pg_provider {
489 let nodes = pg.node_count().unwrap_or(0);
490 log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
491 return (
492 Some(OpenGraphProvider {
493 source: GraphProviderSource::PropertyGraph,
494 provider: GraphProvider::PropertyGraph(pg),
495 }),
496 needs_build,
497 );
498 }
499
500 (None, needs_build)
501}
502
503pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
509 let (existing, needs_build) = open_existing(project_root);
510 if needs_build {
511 trigger_lazy_graph_build(project_root);
512 }
513 existing
514}
515
516fn log_source_selection(
517 source: GraphProviderSource,
518 nodes: usize,
519 edges: usize,
520 start: std::time::Instant,
521) {
522 let elapsed_ms = start.elapsed().as_millis();
523 if std::env::var("LCTX_DEBUG").is_ok() {
524 eprintln!(
525 "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
526 );
527 }
528 let _ = (source, nodes, edges, elapsed_ms);
529}
530
531fn trigger_lazy_graph_build(project_root: &str) {
533 if cfg!(test) {
542 return;
543 }
544 if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
545 return;
546 }
547 let root = Path::new(project_root);
548 let is_project = crate::core::pathutil::has_project_marker(root)
551 || crate::core::pathutil::has_multi_repo_children(root);
552 if !is_project {
553 return;
554 }
555 super::index_orchestrator::ensure_all_background(project_root);
559}
560
561pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
572 let index = super::index_orchestrator::try_load_graph_index(project_root)
573 .filter(|i| !i.files.is_empty())
574 .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
575 super::property_graph::mirror_index(project_root, &index)
576}
577
578pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
579 if let (Some(p), _) = open_existing(project_root) {
585 return Some(p);
586 }
587 let idx = super::graph_index::load_or_build(project_root);
588 if idx.files.is_empty() {
589 return None;
590 }
591 Some(OpenGraphProvider {
592 source: GraphProviderSource::GraphIndex,
593 provider: GraphProvider::GraphIndex(idx),
594 })
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn best_effort_prefers_graph_index_when_property_graph_empty() {
603 let _lock = crate::core::data_dir::test_env_lock();
604 let tmp = tempfile::tempdir().expect("tempdir");
605 let data = tmp.path().join("data");
606 std::fs::create_dir_all(&data).expect("mkdir data");
607 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
608
609 let project_root = tmp.path().join("proj");
610 std::fs::create_dir_all(&project_root).expect("mkdir proj");
611 let root = project_root.to_string_lossy().to_string();
612
613 let mut idx = ProjectIndex::new(&root);
614 idx.files.insert(
615 "src/main.rs".to_string(),
616 super::super::graph_index::FileEntry {
617 path: "src/main.rs".to_string(),
618 hash: "h".to_string(),
619 language: "rs".to_string(),
620 line_count: 1,
621 token_count: 1,
622 exports: vec![],
623 summary: String::new(),
624 },
625 );
626 idx.save().expect("save index");
627
628 let open = open_best_effort(&root).expect("open");
629 assert_eq!(open.source, GraphProviderSource::GraphIndex);
630
631 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
632 }
633
634 #[test]
635 fn best_effort_none_when_no_graphs() {
636 let _lock = crate::core::data_dir::test_env_lock();
637 let tmp = tempfile::tempdir().expect("tempdir");
638 let data = tmp.path().join("data");
639 std::fs::create_dir_all(&data).expect("mkdir data");
640 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
641
642 let project_root = tmp.path().join("proj");
643 std::fs::create_dir_all(&project_root).expect("mkdir proj");
644 let root = project_root.to_string_lossy().to_string();
645
646 let open = open_best_effort(&root);
647 assert!(open.is_none());
648
649 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
650 }
651
652 #[test]
653 fn parity_dependencies_both_stores_agree() {
654 use super::super::graph_index::{FileEntry, IndexEdge};
655 use super::super::property_graph::{Edge, EdgeKind, Node};
656
657 let pg = CodeGraph::open_in_memory().unwrap();
658 let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
659 let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
660 let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
661 pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
662 .unwrap();
663 pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
664 .unwrap();
665
666 let mut idx = ProjectIndex::new("/test");
667 for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
668 idx.files.insert(
669 name.to_string(),
670 FileEntry {
671 path: name.to_string(),
672 hash: "h".into(),
673 language: "rs".into(),
674 line_count: 1,
675 token_count: 1,
676 exports: vec![],
677 summary: String::new(),
678 },
679 );
680 }
681 idx.edges.push(IndexEdge {
682 from: "src/a.rs".into(),
683 to: "src/b.rs".into(),
684 kind: "import".into(),
685 weight: 1.0,
686 });
687 idx.edges.push(IndexEdge {
688 from: "src/a.rs".into(),
689 to: "src/c.rs".into(),
690 kind: "import".into(),
691 weight: 1.0,
692 });
693
694 let pg_deps = GraphProvider::PropertyGraph(pg);
695 let gi_deps = GraphProvider::GraphIndex(idx);
696
697 let mut pg_result = pg_deps.dependencies("src/a.rs");
698 let mut gi_result = gi_deps.dependencies("src/a.rs");
699 pg_result.sort();
700 gi_result.sort();
701
702 assert_eq!(
703 pg_result, gi_result,
704 "Import edges must match between PG and GraphIndex"
705 );
706
707 let mut pg_dependents = pg_deps.dependents("src/b.rs");
708 let mut gi_dependents = gi_deps.dependents("src/b.rs");
709 pg_dependents.sort();
710 gi_dependents.sort();
711 assert_eq!(
712 pg_dependents, gi_dependents,
713 "Dependents must match between PG and GraphIndex"
714 );
715 }
716
717 #[test]
724 fn materialize_project_index_round_trips_losslessly() {
725 use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
726 use super::super::property_graph::populate_from_project_index;
727
728 let mut a = ProjectIndex::new("/test");
729 a.files.insert(
730 "src/a.rs".to_string(),
731 FileEntry {
732 path: "src/a.rs".to_string(),
733 hash: "hash-a".to_string(),
734 language: "rs".to_string(),
735 line_count: 42,
736 token_count: 137,
737 exports: vec!["Foo".to_string()],
738 summary: "module a".to_string(),
739 },
740 );
741 a.files.insert(
742 "src/b.rs".to_string(),
743 FileEntry {
744 path: "src/b.rs".to_string(),
745 hash: "hash-b".to_string(),
746 language: "rs".to_string(),
747 line_count: 7,
748 token_count: 19,
749 exports: vec![],
750 summary: String::new(),
751 },
752 );
753 a.symbols.insert(
756 "src/a.rs::Foo".to_string(),
757 SymbolEntry {
758 file: "src/a.rs".to_string(),
759 name: "Foo".to_string(),
760 kind: "struct".to_string(),
761 start_line: 1,
762 end_line: 9,
763 is_exported: true,
764 },
765 );
766 a.symbols.insert(
767 "src/b.rs::helper".to_string(),
768 SymbolEntry {
769 file: "src/b.rs".to_string(),
770 name: "helper".to_string(),
771 kind: "function".to_string(),
772 start_line: 3,
773 end_line: 6,
774 is_exported: false,
775 },
776 );
777 a.edges.push(IndexEdge {
778 from: "src/b.rs".to_string(),
779 to: "src/a.rs".to_string(),
780 kind: "import".to_string(),
781 weight: 1.0,
782 });
783
784 let pg = CodeGraph::open_in_memory().unwrap();
785 populate_from_project_index(&pg, &a).unwrap();
786 let provider = GraphProvider::PropertyGraph(pg);
787 let b = provider.materialize_project_index("/test");
788
789 let mut a_files: Vec<&String> = a.files.keys().collect();
791 let mut b_files: Vec<&String> = b.files.keys().collect();
792 a_files.sort();
793 b_files.sort();
794 assert_eq!(a_files, b_files, "file inventory must round-trip");
795 for (path, fa) in &a.files {
796 let fb = b.files.get(path).expect("file present after round trip");
797 assert_eq!(fa.hash, fb.hash, "hash {path}");
798 assert_eq!(fa.language, fb.language, "language {path}");
799 assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
800 assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
801 assert_eq!(fa.exports, fb.exports, "exports {path}");
802 assert_eq!(fa.summary, fb.summary, "summary {path}");
803 }
804
805 let mut a_syms: Vec<&String> = a.symbols.keys().collect();
807 let mut b_syms: Vec<&String> = b.symbols.keys().collect();
808 a_syms.sort();
809 b_syms.sort();
810 assert_eq!(a_syms, b_syms, "symbol table must round-trip");
811 for (key, sa) in &a.symbols {
812 let sb = b.symbols.get(key).expect("symbol present after round trip");
813 assert_eq!(sa.name, sb.name, "name {key}");
814 assert_eq!(sa.file, sb.file, "file {key}");
815 assert_eq!(sa.kind, sb.kind, "kind {key}");
816 assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
817 assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
818 assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
819 }
820
821 assert!(
823 b.edges
824 .iter()
825 .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
826 "import edge must round-trip; got {:?}",
827 b.edges
828 );
829 }
830}