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