1use std::path::Path;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use super::graph_index::{self, ProjectIndex};
5use super::property_graph::served::GraphGate;
6use super::property_graph::{CodeGraph, GraphSource};
7
8static GRAPH_BUILD_TRIGGERED: AtomicBool = AtomicBool::new(false);
9
10#[derive(Debug, Clone)]
11pub struct SymbolInfo {
12 pub name: String,
13 pub file: String,
14 pub kind: String,
15 pub start_line: usize,
16 pub end_line: usize,
17 pub is_exported: bool,
18}
19
20fn index_edge_kind(pg_kind: &str) -> String {
28 match pg_kind {
29 "imports" => "import".to_string(),
30 other => other.to_string(),
31 }
32}
33
34fn line_distance(start: usize, target: Option<usize>) -> usize {
38 match target {
39 Some(t) => start.abs_diff(t),
40 None => 0,
41 }
42}
43
44fn symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
50 let (meta_kind, meta_exported) =
51 super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
52 SymbolInfo {
53 kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
54 is_exported: meta_exported.unwrap_or(true),
55 name: n.name,
56 file: n.file_path,
57 start_line: n.line_start.unwrap_or(0),
58 end_line: n.line_end.unwrap_or(0),
59 }
60}
61
62#[derive(Debug, Clone)]
63pub struct EdgeInfo {
64 pub from: String,
65 pub to: String,
66 pub kind: String,
67 pub weight: f64,
68}
69
70#[derive(Debug, Clone)]
71pub struct FileInfo {
72 pub path: String,
73 pub hash: String,
74 pub language: String,
75 pub line_count: usize,
76 pub token_count: usize,
77 pub exports: Vec<String>,
78 pub summary: String,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum GraphProviderSource {
83 PropertyGraph,
84 GraphIndex,
85}
86
87#[allow(clippy::large_enum_variant)]
90pub enum GraphProvider {
91 PropertyGraph(CodeGraph),
92 GraphIndex(ProjectIndex),
93}
94
95pub struct OpenGraphProvider {
96 pub source: GraphProviderSource,
97 pub provider: GraphProvider,
98}
99
100fn edge_file(end: &str) -> &str {
103 end.split_once("::").map_or(end, |(file, _)| file)
104}
105
106impl GraphProvider {
112 fn index_gate(&self) -> Option<GraphGate> {
113 match self {
114 GraphProvider::PropertyGraph(_) => None,
115 GraphProvider::GraphIndex(i) => Some(GraphGate::for_root(&i.project_root)),
116 }
117 }
118
119 fn file_served(&self, gate: Option<&GraphGate>, path: &str) -> bool {
120 match self {
121 GraphProvider::PropertyGraph(g) => g.serves_file(path),
122 GraphProvider::GraphIndex(_) => gate.is_none_or(|gate| gate.file(path)),
123 }
124 }
125
126 fn symbol_served(&self, gate: Option<&GraphGate>, path: &str, name: &str) -> bool {
127 match self {
128 GraphProvider::PropertyGraph(g) => g.serves_symbol(path, name),
129 GraphProvider::GraphIndex(_) => gate.is_none_or(|gate| gate.symbol(path, name)),
130 }
131 }
132
133 fn served_from(&self, anchor: Option<&str>, paths: Vec<String>) -> Vec<String> {
136 let gate = self.index_gate();
137 if anchor.is_some_and(|anchor| !self.file_served(gate.as_ref(), anchor)) {
138 return Vec::new();
139 }
140 paths
141 .into_iter()
142 .filter(|path| self.file_served(gate.as_ref(), path))
143 .collect()
144 }
145
146 fn served_symbols(&self, symbols: Vec<SymbolInfo>) -> Vec<SymbolInfo> {
147 let gate = self.index_gate();
148 symbols
149 .into_iter()
150 .filter(|symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name))
151 .collect()
152 }
153
154 pub fn dependencies(&self, file_path: &str) -> Vec<String> {
155 self.served_from(Some(file_path), self.dependencies_unchecked(file_path))
156 }
157
158 pub fn dependents(&self, file_path: &str) -> Vec<String> {
159 self.served_from(Some(file_path), self.dependents_unchecked(file_path))
160 }
161
162 pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
167 let gate = self.index_gate();
168 let served = |path: &str| self.file_served(gate.as_ref(), path);
169 if !served(file_path) {
170 return Vec::new();
171 }
172 match self {
173 GraphProvider::PropertyGraph(g) => g
174 .impact_analysis(file_path, depth)
175 .map(|r| r.affected_files)
176 .unwrap_or_default(),
177 GraphProvider::GraphIndex(i) => i.get_related_served(file_path, depth, &served),
178 }
179 }
180
181 pub(crate) fn ranking_graph(&self) -> (Vec<String>, Vec<EdgeInfo>) {
186 let mut files = self.file_paths_unchecked();
187 files.sort();
188 files.dedup();
189 let edges = self.edges_unchecked();
190 let pairs: Vec<(String, String)> = edges
191 .iter()
192 .map(|e| (edge_file(&e.from).to_owned(), edge_file(&e.to).to_owned()))
193 .collect();
194 let served = match self {
195 GraphProvider::PropertyGraph(g) => g.rankable_files(files.clone(), &pairs, &[]),
196 GraphProvider::GraphIndex(i) => {
197 GraphGate::for_root(&i.project_root).ranking_files(files.clone(), &pairs, &[])
198 }
199 };
200 files.retain(|file| served.contains(file));
201 let edges = edges
202 .into_iter()
203 .filter(|e| served.contains(edge_file(&e.from)) && served.contains(edge_file(&e.to)))
204 .collect();
205 (files, edges)
206 }
207
208 pub fn file_paths(&self) -> Vec<String> {
209 self.served_from(None, self.file_paths_unchecked())
210 }
211
212 pub fn find_symbols(
213 &self,
214 name: &str,
215 file_filter: Option<&str>,
216 kind_filter: Option<&str>,
217 ) -> Vec<SymbolInfo> {
218 self.served_symbols(self.find_symbols_unchecked(name, file_filter, kind_filter))
219 }
220
221 pub fn all_symbols(&self) -> Vec<SymbolInfo> {
223 self.served_symbols(self.all_symbols_unchecked())
224 }
225
226 pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
227 let gate = self.index_gate();
228 self.get_symbol_unchecked(key)
229 .filter(|symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name))
230 }
231
232 pub fn edges(&self) -> Vec<EdgeInfo> {
233 let gate = self.index_gate();
234 let served = |end: &str| self.file_served(gate.as_ref(), edge_file(end));
235 self.edges_unchecked()
236 .into_iter()
237 .filter(|edge| served(edge.from.as_str()) && served(edge.to.as_str()))
238 .collect()
239 }
240
241 pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
242 let gate = self.index_gate();
243 self.file_served(gate.as_ref(), path)
244 .then(|| self.get_file_entry_unchecked(path))
245 .flatten()
246 }
247
248 pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
253 let gate = self.index_gate();
254 self.related_files_scored_with(gate.as_ref(), file_path, limit)
255 }
256
257 pub(crate) fn related_files_scored_with(
262 &self,
263 gate: Option<&GraphGate>,
264 file_path: &str,
265 limit: usize,
266 ) -> Vec<(String, f64)> {
267 match self {
268 GraphProvider::PropertyGraph(g) => {
269 g.related_files(file_path, limit).unwrap_or_default()
270 }
271 GraphProvider::GraphIndex(_) => {
272 let served = |path: &str| self.file_served(gate, path);
273 let mut result: Vec<(String, f64)> = Vec::new();
274 if !served(file_path) {
275 return result;
276 }
277 let neighbours = self
278 .dependencies_unchecked(file_path)
279 .into_iter()
280 .map(|dep| (dep, 1.0))
281 .chain(
282 self.dependents_unchecked(file_path)
283 .into_iter()
284 .map(|dep| (dep, 0.5)),
285 );
286 for (dep, score) in neighbours {
287 if result.len() >= limit {
288 break;
289 }
290 if !result.iter().any(|(path, _)| *path == dep) && served(&dep) {
291 result.push((dep, score));
292 }
293 }
294 result
295 }
296 }
297 }
298
299 pub fn node_count(&self) -> Option<usize> {
300 match self {
301 GraphProvider::PropertyGraph(g) => g.node_count().ok(),
302 GraphProvider::GraphIndex(i) => Some(i.file_count()),
303 }
304 }
305
306 pub fn edge_count(&self) -> Option<usize> {
307 match self {
308 GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
309 GraphProvider::GraphIndex(i) => Some(i.edge_count()),
310 }
311 }
312
313 fn dependencies_unchecked(&self, file_path: &str) -> Vec<String> {
314 match self {
315 GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
316 GraphProvider::GraphIndex(i) => i
317 .edges
318 .iter()
319 .filter(|e| e.kind == "import" && e.from == file_path)
320 .map(|e| e.to.clone())
321 .collect(),
322 }
323 }
324
325 fn dependents_unchecked(&self, file_path: &str) -> Vec<String> {
326 match self {
327 GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
328 GraphProvider::GraphIndex(i) => i
329 .edges
330 .iter()
331 .filter(|e| e.kind == "import" && e.to == file_path)
332 .map(|e| e.from.clone())
333 .collect(),
334 }
335 }
336
337 fn file_paths_unchecked(&self) -> Vec<String> {
338 match self {
339 GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
340 GraphProvider::GraphIndex(i) => {
341 let mut paths: Vec<String> = i.files.keys().cloned().collect();
342 paths.sort();
343 paths
344 }
345 }
346 }
347
348 pub fn file_count(&self) -> usize {
349 match self {
350 GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
351 GraphProvider::GraphIndex(i) => i.files.len(),
352 }
353 }
354
355 pub fn symbol_count(&self) -> usize {
356 match self {
357 GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
358 GraphProvider::GraphIndex(i) => i.symbols.len(),
359 }
360 }
361
362 fn find_symbols_unchecked(
363 &self,
364 name: &str,
365 file_filter: Option<&str>,
366 kind_filter: Option<&str>,
367 ) -> Vec<SymbolInfo> {
368 match self {
369 GraphProvider::PropertyGraph(g) => g
370 .find_symbols(name, file_filter, None)
379 .unwrap_or_default()
380 .into_iter()
381 .map(symbol_info_from_node)
382 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
383 .collect(),
384 GraphProvider::GraphIndex(i) => {
385 let name_lower = name.to_lowercase();
386 let mut hits: Vec<_> = i
389 .symbols
390 .values()
391 .filter(|s| s.name.to_lowercase().contains(&name_lower))
392 .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
393 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
394 .collect();
395 hits.sort_by(|a, b| {
396 (a.file.as_str(), a.start_line, a.name.as_str()).cmp(&(
397 b.file.as_str(),
398 b.start_line,
399 b.name.as_str(),
400 ))
401 });
402 hits.into_iter()
403 .take(100)
404 .map(|s| SymbolInfo {
405 name: s.name.clone(),
406 file: s.file.clone(),
407 kind: s.kind.clone(),
408 start_line: s.start_line,
409 end_line: s.end_line,
410 is_exported: s.is_exported,
411 })
412 .collect()
413 }
414 }
415 }
416
417 fn all_symbols_unchecked(&self) -> Vec<SymbolInfo> {
421 match self {
422 GraphProvider::PropertyGraph(g) => g
423 .all_symbols()
424 .unwrap_or_default()
425 .into_iter()
426 .map(symbol_info_from_node)
427 .collect(),
428 GraphProvider::GraphIndex(i) => i
429 .symbols
430 .values()
431 .map(|s| SymbolInfo {
432 name: s.name.clone(),
433 file: s.file.clone(),
434 kind: s.kind.clone(),
435 start_line: s.start_line,
436 end_line: s.end_line,
437 is_exported: s.is_exported,
438 })
439 .collect(),
440 }
441 }
442
443 fn get_symbol_unchecked(&self, key: &str) -> Option<SymbolInfo> {
444 match self {
445 GraphProvider::PropertyGraph(g) => {
446 let parts: Vec<&str> = key.splitn(2, "::").collect();
451 if parts.len() != 2 {
452 return None;
453 }
454 let (file_path, sym_name) = (parts[0], parts[1]);
455 g.get_node_by_symbol(sym_name, file_path)
456 .ok()
457 .flatten()
458 .map(symbol_info_from_node)
459 }
460 GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
461 name: s.name.clone(),
462 file: s.file.clone(),
463 kind: s.kind.clone(),
464 start_line: s.start_line,
465 end_line: s.end_line,
466 is_exported: s.is_exported,
467 }),
468 }
469 }
470
471 pub fn find_symbol_by_handle(
486 &self,
487 handle: &crate::core::handle::SymbolHandle,
488 ) -> Option<SymbolInfo> {
489 let key = format!("{}::{}", handle.path, handle.name);
490 if let Some(sym) = self.get_symbol(&key) {
491 return Some(sym);
492 }
493
494 let tail = handle
495 .name
496 .rsplit("::")
497 .next()
498 .unwrap_or(handle.name.as_str());
499 let mut candidates: Vec<SymbolInfo> = self
500 .all_symbols()
501 .into_iter()
502 .filter(|s| s.file == handle.path)
503 .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
504 .collect();
505 if candidates.is_empty() {
506 return None;
507 }
508 candidates.sort_by(|a, b| {
509 let exact_a = u8::from(a.name != handle.name);
510 let exact_b = u8::from(b.name != handle.name);
511 exact_a
512 .cmp(&exact_b)
513 .then_with(|| {
514 line_distance(a.start_line, handle.line)
515 .cmp(&line_distance(b.start_line, handle.line))
516 })
517 .then_with(|| a.start_line.cmp(&b.start_line))
518 .then_with(|| a.name.cmp(&b.name))
519 });
520 candidates.into_iter().next()
521 }
522
523 fn edges_unchecked(&self) -> Vec<EdgeInfo> {
524 match self {
525 GraphProvider::PropertyGraph(g) => g
531 .all_edges_flat()
532 .unwrap_or_default()
533 .into_iter()
534 .map(|(from, to, kind, weight)| EdgeInfo {
535 from,
536 to,
537 kind: index_edge_kind(&kind),
538 weight,
539 })
540 .collect(),
541 GraphProvider::GraphIndex(i) => i
542 .edges
543 .iter()
544 .map(|e| EdgeInfo {
545 from: e.from.clone(),
546 to: e.to.clone(),
547 kind: e.kind.clone(),
548 weight: e.weight as f64,
549 })
550 .collect(),
551 }
552 }
553
554 pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
555 self.edges()
556 .into_iter()
557 .filter(|e| e.kind == kind)
558 .collect()
559 }
560
561 pub fn file_entries(&self) -> Vec<FileInfo> {
565 match self {
566 GraphProvider::PropertyGraph(_) => self
567 .file_paths()
568 .into_iter()
569 .filter_map(|p| self.get_file_entry_unchecked(&p))
570 .collect(),
571 GraphProvider::GraphIndex(i) => {
572 let gate = self.index_gate();
573 i.files
574 .values()
575 .filter(|e| self.file_served(gate.as_ref(), &e.path))
576 .map(|e| FileInfo {
577 path: e.path.clone(),
578 hash: e.hash.clone(),
579 language: e.language.clone(),
580 line_count: e.line_count,
581 token_count: e.token_count,
582 exports: e.exports.clone(),
583 summary: e.summary.clone(),
584 })
585 .collect()
586 }
587 }
588 }
589
590 fn get_file_entry_unchecked(&self, path: &str) -> Option<FileInfo> {
591 match self {
592 GraphProvider::PropertyGraph(g) => {
593 g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
594 path: e.path,
595 hash: e.hash,
596 language: e.language,
597 line_count: e.line_count,
598 token_count: e.token_count,
599 exports: e.exports,
600 summary: e.summary,
601 })
602 }
603 GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
604 path: e.path.clone(),
605 hash: e.hash.clone(),
606 language: e.language.clone(),
607 line_count: e.line_count,
608 token_count: e.token_count,
609 exports: e.exports.clone(),
610 summary: e.summary.clone(),
611 }),
612 }
613 }
614
615 pub fn last_scan(&self) -> String {
616 match self {
617 GraphProvider::PropertyGraph(_) => String::new(),
618 GraphProvider::GraphIndex(i) => i.last_scan.clone(),
619 }
620 }
621
622 pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
631 if let GraphProvider::GraphIndex(i) = self {
632 let gate = self.index_gate();
634 let served = |path: &str| self.file_served(gate.as_ref(), path);
635 let mut index = i.clone();
636 index.files.retain(|path, _| served(path.as_str()));
637 index
638 .symbols
639 .retain(|_, symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name));
640 index
641 .edges
642 .retain(|edge| served(edge_file(&edge.from)) && served(edge_file(&edge.to)));
643 index.rebuild_interner();
644 return index;
645 }
646 let mut idx = ProjectIndex::new(project_root);
647 if let Some(meta) = super::property_graph::load_meta(project_root)
652 && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
653 {
654 idx.last_scan = dt
655 .with_timezone(&chrono::Local)
656 .format("%Y-%m-%d %H:%M:%S")
657 .to_string();
658 }
659 for f in self.file_entries() {
660 idx.files.insert(
661 f.path.clone(),
662 graph_index::FileEntry {
663 path: f.path,
664 hash: f.hash,
665 language: f.language,
666 line_count: f.line_count,
667 token_count: f.token_count,
668 exports: f.exports,
669 summary: f.summary,
670 },
671 );
672 }
673 for s in self.all_symbols() {
674 let key = format!("{}::{}", s.file, s.name);
675 idx.symbols.insert(
676 key,
677 graph_index::SymbolEntry {
678 file: s.file,
679 name: s.name,
680 kind: s.kind,
681 start_line: s.start_line,
682 end_line: s.end_line,
683 is_exported: s.is_exported,
684 },
685 );
686 }
687 for e in self.edges() {
688 idx.edges.push(graph_index::IndexEdge {
691 from: e.from,
692 to: e.to,
693 kind: e.kind,
694 weight: e.weight as f32,
695 });
696 }
697 idx.rebuild_interner();
698 idx
699 }
700
701 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
702 graph_index::ProjectIndex::index_dir(project_root)
703 }
704}
705
706fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
714 let t0 = std::time::Instant::now();
715
716 let mut pg_provider = None;
717 let mut pg_populated = false;
718 let pg_current = !crate::core::property_graph::engine_outdated(project_root);
722 if pg_current && let Ok(pg) = CodeGraph::open(project_root) {
723 let nodes = pg.node_count().unwrap_or(0);
724 let edges = pg.edge_count().unwrap_or(0);
725 let file_cat = pg.file_catalog_count().unwrap_or(0);
726 pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
727 if pg_populated {
728 log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
729 return (
730 Some(OpenGraphProvider {
731 source: GraphProviderSource::PropertyGraph,
732 provider: GraphProvider::PropertyGraph(pg),
733 }),
734 false,
735 );
736 }
737 if nodes > 0 && file_cat > 0 {
738 pg_provider = Some(pg);
739 }
740 }
741
742 let needs_build = !pg_populated;
744
745 if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
746 let files = idx.files.len();
747 let edges = idx.edges.len();
748 if !idx.edges.is_empty() || !idx.files.is_empty() {
749 log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
750 return (
751 Some(OpenGraphProvider {
752 source: GraphProviderSource::GraphIndex,
753 provider: GraphProvider::GraphIndex(idx),
754 }),
755 needs_build,
756 );
757 }
758 }
759
760 if let Some(pg) = pg_provider {
761 let nodes = pg.node_count().unwrap_or(0);
762 log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
763 return (
764 Some(OpenGraphProvider {
765 source: GraphProviderSource::PropertyGraph,
766 provider: GraphProvider::PropertyGraph(pg),
767 }),
768 needs_build,
769 );
770 }
771
772 (None, needs_build)
773}
774
775pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
781 let (existing, needs_build) = open_existing(project_root);
782 if needs_build {
783 trigger_lazy_graph_build(project_root);
784 }
785 existing
786}
787
788pub fn open_ready(project_root: &str) -> Option<OpenGraphProvider> {
795 let ready = if crate::core::property_graph::engine_outdated(project_root) {
796 None
797 } else {
798 CodeGraph::open(project_root).ok().filter(|pg| {
801 pg.node_count().unwrap_or(0) > 0 && pg.file_catalog_count().unwrap_or(0) > 0
802 })
803 };
804 if let Some(pg) = ready {
805 Some(OpenGraphProvider {
806 source: GraphProviderSource::PropertyGraph,
807 provider: GraphProvider::PropertyGraph(pg),
808 })
809 } else {
810 trigger_lazy_graph_build(project_root);
811 None
812 }
813}
814
815fn log_source_selection(
816 source: GraphProviderSource,
817 nodes: usize,
818 edges: usize,
819 start: std::time::Instant,
820) {
821 let elapsed_ms = start.elapsed().as_millis();
822 if std::env::var("LCTX_DEBUG").is_ok() {
823 eprintln!(
824 "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
825 );
826 }
827 let _ = (source, nodes, edges, elapsed_ms);
828}
829
830fn trigger_lazy_graph_build(project_root: &str) {
832 if cfg!(test) {
841 return;
842 }
843 if GRAPH_BUILD_TRIGGERED.load(Ordering::SeqCst) {
846 return;
847 }
848 let root = Path::new(project_root);
849 let is_project = crate::core::pathutil::has_project_marker(root)
852 || crate::core::pathutil::has_multi_repo_children(root);
853 if !is_project {
854 return;
855 }
856 if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
857 return;
858 }
859 super::index_orchestrator::ensure_all_background(project_root);
863}
864
865pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
876 let index = super::index_orchestrator::try_load_graph_index(project_root)
877 .filter(|i| !i.files.is_empty())
878 .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
879 super::property_graph::mirror_index(project_root, &index)
880}
881
882const BUILD_TIMEOUT_SECS: u64 = 30;
886
887static BUILD_GATE: std::sync::LazyLock<std::sync::Mutex<()>> =
892 std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
893
894pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
895 if let (Some(p), _) = open_existing(project_root) {
896 return Some(p);
897 }
898
899 let _gate = if cfg!(test) {
902 BUILD_GATE
903 .lock()
904 .unwrap_or_else(std::sync::PoisonError::into_inner)
905 } else {
906 let Ok(gate) = BUILD_GATE.try_lock() else {
907 tracing::info!(
908 "open_or_build: another build in progress for {project_root}; returning None"
909 );
910 trigger_lazy_graph_build(project_root);
911 return None;
912 };
913 gate
914 };
915
916 let root_owned = project_root.to_string();
919 let (tx, rx) = std::sync::mpsc::sync_channel(1);
920 std::thread::spawn(move || {
921 let idx = super::graph_index::load_or_build(&root_owned);
922 let _ = tx.send(idx);
923 });
924
925 let timeout = std::time::Duration::from_secs(BUILD_TIMEOUT_SECS);
926 match rx.recv_timeout(timeout) {
927 Ok(idx) if !idx.files.is_empty() => Some(OpenGraphProvider {
928 source: GraphProviderSource::GraphIndex,
929 provider: GraphProvider::GraphIndex(idx),
930 }),
931 Ok(_) => None,
932 Err(_) => {
933 tracing::warn!(
934 "open_or_build: graph build timed out after {BUILD_TIMEOUT_SECS}s for {project_root}; \
935 triggering background build"
936 );
937 trigger_lazy_graph_build(project_root);
938 None
939 }
940 }
941}
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 #[test]
947 fn best_effort_prefers_graph_index_when_property_graph_empty() {
948 let _lock = crate::core::data_dir::test_env_lock();
949 let tmp = tempfile::tempdir().expect("tempdir");
950 let data = tmp.path().join("data");
951 std::fs::create_dir_all(&data).expect("mkdir data");
952 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
953
954 let project_root = tmp.path().join("proj");
955 std::fs::create_dir_all(&project_root).expect("mkdir proj");
956 let root = project_root.to_string_lossy().to_string();
957 std::fs::create_dir_all(project_root.join("src")).expect("mkdir src");
959 std::fs::write(project_root.join("src/main.rs"), "fn main() {}\n").expect("source");
960
961 let mut idx = ProjectIndex::new(&root);
962 idx.files.insert(
963 "src/main.rs".to_string(),
964 super::super::graph_index::FileEntry {
965 path: "src/main.rs".to_string(),
966 hash: "h".to_string(),
967 language: "rs".to_string(),
968 line_count: 1,
969 token_count: 1,
970 exports: vec![],
971 summary: String::new(),
972 },
973 );
974 idx.save().expect("save index");
975
976 let open = open_best_effort(&root).expect("open");
977 assert_eq!(open.source, GraphProviderSource::GraphIndex);
978
979 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
980 }
981
982 #[test]
983 fn best_effort_none_when_no_graphs() {
984 let _lock = crate::core::data_dir::test_env_lock();
985 let tmp = tempfile::tempdir().expect("tempdir");
986 let data = tmp.path().join("data");
987 std::fs::create_dir_all(&data).expect("mkdir data");
988 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
989
990 let project_root = tmp.path().join("proj");
991 std::fs::create_dir_all(&project_root).expect("mkdir proj");
992 let root = project_root.to_string_lossy().to_string();
993
994 let open = open_best_effort(&root);
995 assert!(open.is_none());
996
997 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
998 }
999
1000 fn handle_provider() -> (GraphProvider, tempfile::TempDir) {
1001 let mut idx = ProjectIndex::new("/test");
1002 for (key, file, name, kind, start, end) in [
1003 (
1004 "src/lib.rs::Config",
1005 "src/lib.rs",
1006 "Config",
1007 "struct",
1008 5usize,
1009 20usize,
1010 ),
1011 (
1012 "src/lib.rs::Config::load",
1013 "src/lib.rs",
1014 "Config::load",
1015 "method",
1016 22,
1017 35,
1018 ),
1019 ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
1020 ] {
1021 idx.symbols.insert(
1022 key.to_string(),
1023 graph_index::SymbolEntry {
1024 file: file.to_string(),
1025 name: name.to_string(),
1026 kind: kind.to_string(),
1027 start_line: start,
1028 end_line: end,
1029 is_exported: true,
1030 },
1031 );
1032 }
1033 let (idx, files) = idx.on_disk(&[]);
1034 (GraphProvider::GraphIndex(idx), files)
1035 }
1036
1037 #[test]
1038 fn handle_resolves_exact_file_and_name() {
1039 let (gp, _files) = handle_provider();
1040 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
1041 let sym = gp.find_symbol_by_handle(&h).expect("resolves");
1042 assert_eq!(sym.name, "Config::load");
1043 assert_eq!(sym.start_line, 22);
1044 }
1045
1046 #[test]
1047 fn handle_resolves_after_line_drift() {
1048 let (gp, _files) = handle_provider();
1051 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
1052 let sym = gp
1053 .find_symbol_by_handle(&h)
1054 .expect("resolves despite drift");
1055 assert_eq!(sym.start_line, 22);
1056 }
1057
1058 #[test]
1059 fn handle_resolves_by_unqualified_tail() {
1060 let (gp, _files) = handle_provider();
1063 let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
1064 let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
1065 assert_eq!(sym.name, "Config::load");
1066 }
1067
1068 #[test]
1069 fn handle_unknown_file_returns_none() {
1070 let (gp, _files) = handle_provider();
1071 let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
1072 assert!(gp.find_symbol_by_handle(&h).is_none());
1073 }
1074
1075 #[test]
1076 fn pg_kind_filter_matches_precise_metadata_kind() {
1077 use super::super::property_graph::{Node, NodeKind};
1081
1082 let pg = CodeGraph::open_in_memory().unwrap();
1083 pg.upsert_node(
1084 &Node::symbol("State", "simplex/simplex.go", NodeKind::Symbol)
1085 .with_lines(129, 140)
1086 .with_metadata(r#"{"kind":"struct","exported":true}"#),
1087 )
1088 .unwrap();
1089 let gp = GraphProvider::PropertyGraph(pg);
1090
1091 let hits = gp.find_symbols("State", None, Some("struct"));
1092 assert_eq!(
1093 hits.len(),
1094 1,
1095 "kind=struct must resolve the exported struct"
1096 );
1097 assert_eq!(hits[0].kind, "struct");
1098 assert!(hits[0].is_exported);
1099
1100 assert!(gp.find_symbols("State", None, Some("fn")).is_empty());
1102 }
1103
1104 #[test]
1105 fn parity_dependencies_both_stores_agree() {
1106 use super::super::graph_index::{FileEntry, IndexEdge};
1107 use super::super::property_graph::{Edge, EdgeKind, Node};
1108
1109 let pg = CodeGraph::open_in_memory().unwrap();
1110 let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
1111 let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
1112 let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
1113 pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
1114 .unwrap();
1115 pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
1116 .unwrap();
1117
1118 let mut idx = ProjectIndex::new("/test");
1119 for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
1120 idx.files.insert(
1121 name.to_string(),
1122 FileEntry {
1123 path: name.to_string(),
1124 hash: "h".into(),
1125 language: "rs".into(),
1126 line_count: 1,
1127 token_count: 1,
1128 exports: vec![],
1129 summary: String::new(),
1130 },
1131 );
1132 }
1133 idx.edges.push(IndexEdge {
1134 from: "src/a.rs".into(),
1135 to: "src/b.rs".into(),
1136 kind: "import".into(),
1137 weight: 1.0,
1138 });
1139 idx.edges.push(IndexEdge {
1140 from: "src/a.rs".into(),
1141 to: "src/c.rs".into(),
1142 kind: "import".into(),
1143 weight: 1.0,
1144 });
1145
1146 let pg_deps = GraphProvider::PropertyGraph(pg);
1147 let (idx, _files) = idx.on_disk(&[]);
1148 let gi_deps = GraphProvider::GraphIndex(idx);
1149
1150 let mut pg_result = pg_deps.dependencies("src/a.rs");
1151 let mut gi_result = gi_deps.dependencies("src/a.rs");
1152 pg_result.sort();
1153 gi_result.sort();
1154
1155 assert_eq!(
1156 pg_result, gi_result,
1157 "Import edges must match between PG and GraphIndex"
1158 );
1159
1160 let mut pg_dependents = pg_deps.dependents("src/b.rs");
1161 let mut gi_dependents = gi_deps.dependents("src/b.rs");
1162 pg_dependents.sort();
1163 gi_dependents.sort();
1164 assert_eq!(
1165 pg_dependents, gi_dependents,
1166 "Dependents must match between PG and GraphIndex"
1167 );
1168 }
1169
1170 #[test]
1177 fn materialize_project_index_round_trips_losslessly() {
1178 use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
1179 use super::super::property_graph::populate_from_project_index;
1180
1181 let mut a = ProjectIndex::new("/test");
1182 a.files.insert(
1183 "src/a.rs".to_string(),
1184 FileEntry {
1185 path: "src/a.rs".to_string(),
1186 hash: "hash-a".to_string(),
1187 language: "rs".to_string(),
1188 line_count: 42,
1189 token_count: 137,
1190 exports: vec!["Foo".to_string()],
1191 summary: "module a".to_string(),
1192 },
1193 );
1194 a.files.insert(
1195 "src/b.rs".to_string(),
1196 FileEntry {
1197 path: "src/b.rs".to_string(),
1198 hash: "hash-b".to_string(),
1199 language: "rs".to_string(),
1200 line_count: 7,
1201 token_count: 19,
1202 exports: vec![],
1203 summary: String::new(),
1204 },
1205 );
1206 a.symbols.insert(
1209 "src/a.rs::Foo".to_string(),
1210 SymbolEntry {
1211 file: "src/a.rs".to_string(),
1212 name: "Foo".to_string(),
1213 kind: "struct".to_string(),
1214 start_line: 1,
1215 end_line: 9,
1216 is_exported: true,
1217 },
1218 );
1219 a.symbols.insert(
1220 "src/b.rs::helper".to_string(),
1221 SymbolEntry {
1222 file: "src/b.rs".to_string(),
1223 name: "helper".to_string(),
1224 kind: "function".to_string(),
1225 start_line: 3,
1226 end_line: 6,
1227 is_exported: false,
1228 },
1229 );
1230 a.edges.push(IndexEdge {
1231 from: "src/b.rs".to_string(),
1232 to: "src/a.rs".to_string(),
1233 kind: "import".to_string(),
1234 weight: 1.0,
1235 });
1236
1237 let pg = CodeGraph::open_in_memory().unwrap();
1238 populate_from_project_index(&pg, &a).unwrap();
1239 let provider = GraphProvider::PropertyGraph(pg);
1240 let b = provider.materialize_project_index("/test");
1241
1242 let mut a_files: Vec<&String> = a.files.keys().collect();
1244 let mut b_files: Vec<&String> = b.files.keys().collect();
1245 a_files.sort();
1246 b_files.sort();
1247 assert_eq!(a_files, b_files, "file inventory must round-trip");
1248 for (path, fa) in &a.files {
1249 let fb = b.files.get(path).expect("file present after round trip");
1250 assert_eq!(fa.hash, fb.hash, "hash {path}");
1251 assert_eq!(fa.language, fb.language, "language {path}");
1252 assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
1253 assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
1254 assert_eq!(fa.exports, fb.exports, "exports {path}");
1255 assert_eq!(fa.summary, fb.summary, "summary {path}");
1256 }
1257
1258 let mut a_syms: Vec<&String> = a.symbols.keys().collect();
1260 let mut b_syms: Vec<&String> = b.symbols.keys().collect();
1261 a_syms.sort();
1262 b_syms.sort();
1263 assert_eq!(a_syms, b_syms, "symbol table must round-trip");
1264 for (key, sa) in &a.symbols {
1265 let sb = b.symbols.get(key).expect("symbol present after round trip");
1266 assert_eq!(sa.name, sb.name, "name {key}");
1267 assert_eq!(sa.file, sb.file, "file {key}");
1268 assert_eq!(sa.kind, sb.kind, "kind {key}");
1269 assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
1270 assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
1271 assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
1272 }
1273
1274 assert!(
1276 b.edges
1277 .iter()
1278 .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
1279 "import edge must round-trip; got {:?}",
1280 b.edges
1281 );
1282 }
1283}