Skip to main content

lean_ctx/core/
graph_provider.rs

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    /// Scored related files using multi-edge weights.
270    /// Falls back to unscored deps/dependents for GraphIndex backend.
271    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
357/// Triggers a background graph build once per process when the graph is empty.
358fn 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        // TODO(arch): calls into tools::ctx_impact -- should use a trait/callback
375        // to decouple core from tools layer.
376        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}