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
19#[derive(Debug, Clone)]
20pub struct EdgeInfo {
21 pub from: String,
22 pub to: String,
23 pub kind: String,
24 pub weight: f64,
25}
26
27#[derive(Debug, Clone)]
28pub struct FileInfo {
29 pub path: String,
30 pub hash: String,
31 pub language: String,
32 pub line_count: usize,
33 pub token_count: usize,
34 pub exports: Vec<String>,
35 pub summary: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum GraphProviderSource {
40 PropertyGraph,
41 GraphIndex,
42}
43
44pub enum GraphProvider {
45 PropertyGraph(CodeGraph),
46 GraphIndex(ProjectIndex),
47}
48
49pub struct OpenGraphProvider {
50 pub source: GraphProviderSource,
51 pub provider: GraphProvider,
52}
53
54impl GraphProvider {
55 pub fn node_count(&self) -> Option<usize> {
56 match self {
57 GraphProvider::PropertyGraph(g) => g.node_count().ok(),
58 GraphProvider::GraphIndex(i) => Some(i.file_count()),
59 }
60 }
61
62 pub fn edge_count(&self) -> Option<usize> {
63 match self {
64 GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
65 GraphProvider::GraphIndex(i) => Some(i.edge_count()),
66 }
67 }
68
69 pub fn as_graph_index(&self) -> Option<&ProjectIndex> {
73 match self {
74 GraphProvider::GraphIndex(i) => Some(i),
75 GraphProvider::PropertyGraph(_) => None,
76 }
77 }
78
79 pub fn dependencies(&self, file_path: &str) -> Vec<String> {
80 match self {
81 GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
82 GraphProvider::GraphIndex(i) => i
83 .edges
84 .iter()
85 .filter(|e| e.kind == "import" && e.from == file_path)
86 .map(|e| e.to.clone())
87 .collect(),
88 }
89 }
90
91 pub fn dependents(&self, file_path: &str) -> Vec<String> {
92 match self {
93 GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
94 GraphProvider::GraphIndex(i) => i
95 .edges
96 .iter()
97 .filter(|e| e.kind == "import" && e.to == file_path)
98 .map(|e| e.from.clone())
99 .collect(),
100 }
101 }
102
103 pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
104 match self {
105 GraphProvider::PropertyGraph(g) => g
106 .impact_analysis(file_path, depth)
107 .map(|r| r.affected_files)
108 .unwrap_or_default(),
109 GraphProvider::GraphIndex(i) => i.get_related(file_path, depth),
110 }
111 }
112
113 pub fn file_paths(&self) -> Vec<String> {
114 match self {
115 GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
116 GraphProvider::GraphIndex(i) => {
117 let mut paths: Vec<String> = i.files.keys().cloned().collect();
118 paths.sort();
119 paths
120 }
121 }
122 }
123
124 pub fn file_count(&self) -> usize {
125 match self {
126 GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
127 GraphProvider::GraphIndex(i) => i.files.len(),
128 }
129 }
130
131 pub fn symbol_count(&self) -> usize {
132 match self {
133 GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
134 GraphProvider::GraphIndex(i) => i.symbols.len(),
135 }
136 }
137
138 pub fn find_symbols(
139 &self,
140 name: &str,
141 file_filter: Option<&str>,
142 kind_filter: Option<&str>,
143 ) -> Vec<SymbolInfo> {
144 match self {
145 GraphProvider::PropertyGraph(g) => g
146 .find_symbols(name, file_filter, kind_filter)
147 .unwrap_or_default()
148 .into_iter()
149 .map(|n| SymbolInfo {
150 name: n.name,
151 file: n.file_path,
152 kind: n.kind.as_str().to_string(),
153 start_line: n.line_start.unwrap_or(0),
154 end_line: n.line_end.unwrap_or(0),
155 is_exported: true,
156 })
157 .collect(),
158 GraphProvider::GraphIndex(i) => {
159 let name_lower = name.to_lowercase();
160 i.symbols
161 .values()
162 .filter(|s| s.name.to_lowercase().contains(&name_lower))
163 .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
164 .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
165 .take(100)
166 .map(|s| SymbolInfo {
167 name: s.name.clone(),
168 file: s.file.clone(),
169 kind: s.kind.clone(),
170 start_line: s.start_line,
171 end_line: s.end_line,
172 is_exported: s.is_exported,
173 })
174 .collect()
175 }
176 }
177 }
178
179 pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
180 match self {
181 GraphProvider::PropertyGraph(g) => {
182 let parts: Vec<&str> = key.rsplitn(2, "::").collect();
183 if parts.len() != 2 {
184 return None;
185 }
186 let (sym_name, file_path) = (parts[0], parts[1]);
187 g.get_node_by_symbol(sym_name, file_path)
188 .ok()
189 .flatten()
190 .map(|n| SymbolInfo {
191 name: n.name,
192 file: n.file_path,
193 kind: n.kind.as_str().to_string(),
194 start_line: n.line_start.unwrap_or(0),
195 end_line: n.line_end.unwrap_or(0),
196 is_exported: true,
197 })
198 }
199 GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
200 name: s.name.clone(),
201 file: s.file.clone(),
202 kind: s.kind.clone(),
203 start_line: s.start_line,
204 end_line: s.end_line,
205 is_exported: s.is_exported,
206 }),
207 }
208 }
209
210 pub fn edges(&self) -> Vec<EdgeInfo> {
211 match self {
212 GraphProvider::PropertyGraph(g) => g
213 .all_edges_flat()
214 .unwrap_or_default()
215 .into_iter()
216 .map(|(from, to, kind, weight)| EdgeInfo {
217 from,
218 to,
219 kind,
220 weight,
221 })
222 .collect(),
223 GraphProvider::GraphIndex(i) => i
224 .edges
225 .iter()
226 .map(|e| EdgeInfo {
227 from: e.from.clone(),
228 to: e.to.clone(),
229 kind: e.kind.clone(),
230 weight: e.weight as f64,
231 })
232 .collect(),
233 }
234 }
235
236 pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
237 self.edges()
238 .into_iter()
239 .filter(|e| e.kind == kind)
240 .collect()
241 }
242
243 pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
244 match self {
245 GraphProvider::PropertyGraph(g) => {
246 g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
247 path: e.path,
248 hash: e.hash,
249 language: e.language,
250 line_count: e.line_count,
251 token_count: e.token_count,
252 exports: e.exports,
253 summary: e.summary,
254 })
255 }
256 GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
257 path: e.path.clone(),
258 hash: e.hash.clone(),
259 language: e.language.clone(),
260 line_count: e.line_count,
261 token_count: e.token_count,
262 exports: e.exports.clone(),
263 summary: e.summary.clone(),
264 }),
265 }
266 }
267
268 pub fn last_scan(&self) -> String {
269 match self {
270 GraphProvider::PropertyGraph(_) => String::new(),
271 GraphProvider::GraphIndex(i) => i.last_scan.clone(),
272 }
273 }
274
275 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
276 graph_index::ProjectIndex::index_dir(project_root)
277 }
278
279 pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
282 match self {
283 GraphProvider::PropertyGraph(g) => {
284 g.related_files(file_path, limit).unwrap_or_default()
285 }
286 GraphProvider::GraphIndex(_) => {
287 let mut result: Vec<(String, f64)> = Vec::new();
288 for dep in self.dependencies(file_path) {
289 result.push((dep, 1.0));
290 }
291 for dep in self.dependents(file_path) {
292 if !result.iter().any(|(p, _)| *p == dep) {
293 result.push((dep, 0.5));
294 }
295 }
296 result.truncate(limit);
297 result
298 }
299 }
300 }
301}
302
303pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
304 let t0 = std::time::Instant::now();
305 let mut pg_provider = None;
306 let mut pg_populated = false;
307 if let Ok(pg) = CodeGraph::open(project_root) {
308 let nodes = pg.node_count().unwrap_or(0);
309 let edges = pg.edge_count().unwrap_or(0);
310 let file_cat = pg.file_catalog_count().unwrap_or(0);
311 pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
312 if pg_populated {
313 log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
314 return Some(OpenGraphProvider {
315 source: GraphProviderSource::PropertyGraph,
316 provider: GraphProvider::PropertyGraph(pg),
317 });
318 }
319 if nodes > 0 && file_cat > 0 {
320 pg_provider = Some(pg);
321 }
322 }
323
324 if !pg_populated {
325 trigger_lazy_graph_build(project_root);
326 }
327
328 if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
329 let files = idx.files.len();
330 let edges = idx.edges.len();
331 if !idx.edges.is_empty() || !idx.files.is_empty() {
332 log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
333 return Some(OpenGraphProvider {
334 source: GraphProviderSource::GraphIndex,
335 provider: GraphProvider::GraphIndex(idx),
336 });
337 }
338 }
339
340 if let Some(pg) = pg_provider {
341 let nodes = pg.node_count().unwrap_or(0);
342 log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
343 return Some(OpenGraphProvider {
344 source: GraphProviderSource::PropertyGraph,
345 provider: GraphProvider::PropertyGraph(pg),
346 });
347 }
348
349 None
350}
351
352fn log_source_selection(
353 source: GraphProviderSource,
354 nodes: usize,
355 edges: usize,
356 start: std::time::Instant,
357) {
358 let elapsed_ms = start.elapsed().as_millis();
359 if std::env::var("LCTX_DEBUG").is_ok() {
360 eprintln!(
361 "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
362 );
363 }
364 let _ = (source, nodes, edges, elapsed_ms);
365}
366
367fn trigger_lazy_graph_build(project_root: &str) {
369 if cfg!(test) {
378 return;
379 }
380 if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
381 return;
382 }
383 let root = Path::new(project_root);
384 let is_project = root.is_dir()
385 && (root.join(".git").exists()
386 || root.join("Cargo.toml").exists()
387 || root.join("package.json").exists()
388 || root.join("go.mod").exists()
389 || crate::core::pathutil::has_multi_repo_children(root));
390 if !is_project {
391 return;
392 }
393 let root_owned = project_root.to_string();
394 std::thread::spawn(move || {
395 let _ = crate::tools::ctx_impact::handle("build", None, &root_owned, None, None);
398 });
399}
400
401pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
402 if let Some(p) = open_best_effort(project_root) {
403 return Some(p);
404 }
405 let idx = super::graph_index::load_or_build(project_root);
406 if idx.files.is_empty() {
407 return None;
408 }
409 Some(OpenGraphProvider {
410 source: GraphProviderSource::GraphIndex,
411 provider: GraphProvider::GraphIndex(idx),
412 })
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn best_effort_prefers_graph_index_when_property_graph_empty() {
421 let _lock = crate::core::data_dir::test_env_lock();
422 let tmp = tempfile::tempdir().expect("tempdir");
423 let data = tmp.path().join("data");
424 std::fs::create_dir_all(&data).expect("mkdir data");
425 std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
426
427 let project_root = tmp.path().join("proj");
428 std::fs::create_dir_all(&project_root).expect("mkdir proj");
429 let root = project_root.to_string_lossy().to_string();
430
431 let mut idx = ProjectIndex::new(&root);
432 idx.files.insert(
433 "src/main.rs".to_string(),
434 super::super::graph_index::FileEntry {
435 path: "src/main.rs".to_string(),
436 hash: "h".to_string(),
437 language: "rs".to_string(),
438 line_count: 1,
439 token_count: 1,
440 exports: vec![],
441 summary: String::new(),
442 },
443 );
444 idx.save().expect("save index");
445
446 let open = open_best_effort(&root).expect("open");
447 assert_eq!(open.source, GraphProviderSource::GraphIndex);
448
449 std::env::remove_var("LEAN_CTX_DATA_DIR");
450 }
451
452 #[test]
453 fn best_effort_none_when_no_graphs() {
454 let _lock = crate::core::data_dir::test_env_lock();
455 let tmp = tempfile::tempdir().expect("tempdir");
456 let data = tmp.path().join("data");
457 std::fs::create_dir_all(&data).expect("mkdir data");
458 std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
459
460 let project_root = tmp.path().join("proj");
461 std::fs::create_dir_all(&project_root).expect("mkdir proj");
462 let root = project_root.to_string_lossy().to_string();
463
464 let open = open_best_effort(&root);
465 assert!(open.is_none());
466
467 std::env::remove_var("LEAN_CTX_DATA_DIR");
468 }
469
470 #[test]
471 fn parity_dependencies_both_stores_agree() {
472 use super::super::graph_index::{FileEntry, IndexEdge};
473 use super::super::property_graph::{Edge, EdgeKind, Node};
474
475 let pg = CodeGraph::open_in_memory().unwrap();
476 let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
477 let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
478 let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
479 pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
480 .unwrap();
481 pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
482 .unwrap();
483
484 let mut idx = ProjectIndex::new("/test");
485 for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
486 idx.files.insert(
487 name.to_string(),
488 FileEntry {
489 path: name.to_string(),
490 hash: "h".into(),
491 language: "rs".into(),
492 line_count: 1,
493 token_count: 1,
494 exports: vec![],
495 summary: String::new(),
496 },
497 );
498 }
499 idx.edges.push(IndexEdge {
500 from: "src/a.rs".into(),
501 to: "src/b.rs".into(),
502 kind: "import".into(),
503 weight: 1.0,
504 });
505 idx.edges.push(IndexEdge {
506 from: "src/a.rs".into(),
507 to: "src/c.rs".into(),
508 kind: "import".into(),
509 weight: 1.0,
510 });
511
512 let pg_deps = GraphProvider::PropertyGraph(pg);
513 let gi_deps = GraphProvider::GraphIndex(idx);
514
515 let mut pg_result = pg_deps.dependencies("src/a.rs");
516 let mut gi_result = gi_deps.dependencies("src/a.rs");
517 pg_result.sort();
518 gi_result.sort();
519
520 assert_eq!(
521 pg_result, gi_result,
522 "Import edges must match between PG and GraphIndex"
523 );
524
525 let mut pg_dependents = pg_deps.dependents("src/b.rs");
526 let mut gi_dependents = gi_deps.dependents("src/b.rs");
527 pg_dependents.sort();
528 gi_dependents.sort();
529 assert_eq!(
530 pg_dependents, gi_dependents,
531 "Dependents must match between PG and GraphIndex"
532 );
533 }
534}