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    /// The underlying [`ProjectIndex`] when this provider is index-backed; `None`
70    /// for the property-graph backend. Lets callers compute index-derived
71    /// analyses (e.g. realized per-language coverage) without re-opening it.
72    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    /// Scored related files using multi-edge weights.
280    /// Falls back to unscored deps/dependents for GraphIndex backend.
281    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
367/// Triggers a background graph build once per process when the graph is empty.
368fn trigger_lazy_graph_build(project_root: &str) {
369    // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses
370    // its own tempdir). A detached, fire-and-forget build thread reads that global
371    // mid-flight and runs concurrently with the otherwise-serial (`--test-threads=1`)
372    // test bodies — the one source of graph-state concurrency in the suite, and the
373    // root of an intermittent macOS-only flake where a freshly-built index appeared
374    // empty to the asserting test. `open_or_build` has a synchronous fallback that
375    // fully covers tests, so skip the background build under `cfg!(test)`. Production
376    // (and integration tests, which run the lib normally) are unaffected.
377    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    // Both probes are TCC-guarded (#356): a non-existent/non-dir path has no
385    // markers, and a launchd-standalone process never stats under ~/Documents.
386    let is_project = crate::core::pathutil::has_project_marker(root)
387        || crate::core::pathutil::has_multi_repo_children(root);
388    if !is_project {
389        return;
390    }
391    let root_owned = project_root.to_string();
392    std::thread::spawn(move || {
393        // TODO(arch): calls into tools::ctx_impact -- should use a trait/callback
394        // to decouple core from tools layer.
395        let _ = crate::tools::ctx_impact::handle("build", None, &root_owned, None, None);
396    });
397}
398
399pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
400    if let Some(p) = open_best_effort(project_root) {
401        return Some(p);
402    }
403    let idx = super::graph_index::load_or_build(project_root);
404    if idx.files.is_empty() {
405        return None;
406    }
407    Some(OpenGraphProvider {
408        source: GraphProviderSource::GraphIndex,
409        provider: GraphProvider::GraphIndex(idx),
410    })
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn best_effort_prefers_graph_index_when_property_graph_empty() {
419        let _lock = crate::core::data_dir::test_env_lock();
420        let tmp = tempfile::tempdir().expect("tempdir");
421        let data = tmp.path().join("data");
422        std::fs::create_dir_all(&data).expect("mkdir data");
423        std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
424
425        let project_root = tmp.path().join("proj");
426        std::fs::create_dir_all(&project_root).expect("mkdir proj");
427        let root = project_root.to_string_lossy().to_string();
428
429        let mut idx = ProjectIndex::new(&root);
430        idx.files.insert(
431            "src/main.rs".to_string(),
432            super::super::graph_index::FileEntry {
433                path: "src/main.rs".to_string(),
434                hash: "h".to_string(),
435                language: "rs".to_string(),
436                line_count: 1,
437                token_count: 1,
438                exports: vec![],
439                summary: String::new(),
440            },
441        );
442        idx.save().expect("save index");
443
444        let open = open_best_effort(&root).expect("open");
445        assert_eq!(open.source, GraphProviderSource::GraphIndex);
446
447        std::env::remove_var("LEAN_CTX_DATA_DIR");
448    }
449
450    #[test]
451    fn best_effort_none_when_no_graphs() {
452        let _lock = crate::core::data_dir::test_env_lock();
453        let tmp = tempfile::tempdir().expect("tempdir");
454        let data = tmp.path().join("data");
455        std::fs::create_dir_all(&data).expect("mkdir data");
456        std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
457
458        let project_root = tmp.path().join("proj");
459        std::fs::create_dir_all(&project_root).expect("mkdir proj");
460        let root = project_root.to_string_lossy().to_string();
461
462        let open = open_best_effort(&root);
463        assert!(open.is_none());
464
465        std::env::remove_var("LEAN_CTX_DATA_DIR");
466    }
467
468    #[test]
469    fn parity_dependencies_both_stores_agree() {
470        use super::super::graph_index::{FileEntry, IndexEdge};
471        use super::super::property_graph::{Edge, EdgeKind, Node};
472
473        let pg = CodeGraph::open_in_memory().unwrap();
474        let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
475        let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
476        let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
477        pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
478            .unwrap();
479        pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
480            .unwrap();
481
482        let mut idx = ProjectIndex::new("/test");
483        for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
484            idx.files.insert(
485                name.to_string(),
486                FileEntry {
487                    path: name.to_string(),
488                    hash: "h".into(),
489                    language: "rs".into(),
490                    line_count: 1,
491                    token_count: 1,
492                    exports: vec![],
493                    summary: String::new(),
494                },
495            );
496        }
497        idx.edges.push(IndexEdge {
498            from: "src/a.rs".into(),
499            to: "src/b.rs".into(),
500            kind: "import".into(),
501            weight: 1.0,
502        });
503        idx.edges.push(IndexEdge {
504            from: "src/a.rs".into(),
505            to: "src/c.rs".into(),
506            kind: "import".into(),
507            weight: 1.0,
508        });
509
510        let pg_deps = GraphProvider::PropertyGraph(pg);
511        let gi_deps = GraphProvider::GraphIndex(idx);
512
513        let mut pg_result = pg_deps.dependencies("src/a.rs");
514        let mut gi_result = gi_deps.dependencies("src/a.rs");
515        pg_result.sort();
516        gi_result.sort();
517
518        assert_eq!(
519            pg_result, gi_result,
520            "Import edges must match between PG and GraphIndex"
521        );
522
523        let mut pg_dependents = pg_deps.dependents("src/b.rs");
524        let mut gi_dependents = gi_deps.dependents("src/b.rs");
525        pg_dependents.sort();
526        gi_dependents.sort();
527        assert_eq!(
528            pg_dependents, gi_dependents,
529            "Dependents must match between PG and GraphIndex"
530        );
531    }
532}