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/// Normalize a property-graph edge-kind string back to the graph_index spelling
20/// when materializing a [`ProjectIndex`]. The mirror maps graph_index `import` →
21/// `EdgeKind::Imports`, which serializes as the plural `imports`; the legacy
22/// dependency consumers (`graph_index` BFS, the facade's index-backed
23/// `dependencies`/`dependents`, `ctx_graph`) filter on the singular `import`, so
24/// reverse exactly that one rename. All other kinds already share their spelling
25/// across both stores (#696 C1).
26fn index_edge_kind(pg_kind: &str) -> String {
27    match pg_kind {
28        "imports" => "import".to_string(),
29        other => other.to_string(),
30    }
31}
32
33/// Absolute distance from a symbol's `start` line to a handle's hint `line`.
34/// A missing hint contributes zero distance, so handles without an `@Lline`
35/// suffix neither help nor hurt the tiebreak (all candidates rank equal here).
36fn line_distance(start: usize, target: Option<usize>) -> usize {
37    match target {
38        Some(t) => start.abs_diff(t),
39        None => 0,
40    }
41}
42
43/// Convert a property-graph symbol [`Node`] into a backend-agnostic
44/// [`SymbolInfo`], recovering the precise source `kind` and export flag from the
45/// node metadata (the `Node` itself only carries a coarse `NodeKind`). Single
46/// source of truth for the three facade methods that surface PG symbols, so a
47/// materialized `ProjectIndex` is lossless (#696 C1).
48fn symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
49    let (meta_kind, meta_exported) =
50        super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
51    SymbolInfo {
52        kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
53        is_exported: meta_exported.unwrap_or(true),
54        name: n.name,
55        file: n.file_path,
56        start_line: n.line_start.unwrap_or(0),
57        end_line: n.line_end.unwrap_or(0),
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct EdgeInfo {
63    pub from: String,
64    pub to: String,
65    pub kind: String,
66    pub weight: f64,
67}
68
69#[derive(Debug, Clone)]
70pub struct FileInfo {
71    pub path: String,
72    pub hash: String,
73    pub language: String,
74    pub line_count: usize,
75    pub token_count: usize,
76    pub exports: Vec<String>,
77    pub summary: String,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum GraphProviderSource {
82    PropertyGraph,
83    GraphIndex,
84}
85
86pub enum GraphProvider {
87    PropertyGraph(CodeGraph),
88    GraphIndex(ProjectIndex),
89}
90
91pub struct OpenGraphProvider {
92    pub source: GraphProviderSource,
93    pub provider: GraphProvider,
94}
95
96impl GraphProvider {
97    pub fn node_count(&self) -> Option<usize> {
98        match self {
99            GraphProvider::PropertyGraph(g) => g.node_count().ok(),
100            GraphProvider::GraphIndex(i) => Some(i.file_count()),
101        }
102    }
103
104    pub fn edge_count(&self) -> Option<usize> {
105        match self {
106            GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
107            GraphProvider::GraphIndex(i) => Some(i.edge_count()),
108        }
109    }
110
111    pub fn dependencies(&self, file_path: &str) -> Vec<String> {
112        match self {
113            GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
114            GraphProvider::GraphIndex(i) => i
115                .edges
116                .iter()
117                .filter(|e| e.kind == "import" && e.from == file_path)
118                .map(|e| e.to.clone())
119                .collect(),
120        }
121    }
122
123    pub fn dependents(&self, file_path: &str) -> Vec<String> {
124        match self {
125            GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
126            GraphProvider::GraphIndex(i) => i
127                .edges
128                .iter()
129                .filter(|e| e.kind == "import" && e.to == file_path)
130                .map(|e| e.from.clone())
131                .collect(),
132        }
133    }
134
135    pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
136        match self {
137            GraphProvider::PropertyGraph(g) => g
138                .impact_analysis(file_path, depth)
139                .map(|r| r.affected_files)
140                .unwrap_or_default(),
141            GraphProvider::GraphIndex(i) => i.get_related(file_path, depth),
142        }
143    }
144
145    pub fn file_paths(&self) -> Vec<String> {
146        match self {
147            GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
148            GraphProvider::GraphIndex(i) => {
149                let mut paths: Vec<String> = i.files.keys().cloned().collect();
150                paths.sort();
151                paths
152            }
153        }
154    }
155
156    pub fn file_count(&self) -> usize {
157        match self {
158            GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
159            GraphProvider::GraphIndex(i) => i.files.len(),
160        }
161    }
162
163    pub fn symbol_count(&self) -> usize {
164        match self {
165            GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
166            GraphProvider::GraphIndex(i) => i.symbols.len(),
167        }
168    }
169
170    pub fn find_symbols(
171        &self,
172        name: &str,
173        file_filter: Option<&str>,
174        kind_filter: Option<&str>,
175    ) -> Vec<SymbolInfo> {
176        match self {
177            GraphProvider::PropertyGraph(g) => g
178                // Kind is filtered AFTER mapping, not in SQL: the PG `nodes.kind`
179                // column is the coarse `NodeKind` (every code symbol is `"symbol"`),
180                // while the precise source kind (`struct`, `fn`, …) lives in metadata
181                // and is recovered by `symbol_info_from_node`. Passing `kind_filter`
182                // to the SQL query matched it against `"symbol"` and dropped every
183                // hit — an exported struct queried with kind="struct" came back empty
184                // (#889). ponytail: the SQL LIMIT 100 caps candidates pre-kind-filter,
185                // fine until a name substring has >100 cross-kind matches.
186                .find_symbols(name, file_filter, None)
187                .unwrap_or_default()
188                .into_iter()
189                .map(symbol_info_from_node)
190                .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
191                .collect(),
192            GraphProvider::GraphIndex(i) => {
193                let name_lower = name.to_lowercase();
194                i.symbols
195                    .values()
196                    .filter(|s| s.name.to_lowercase().contains(&name_lower))
197                    .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
198                    .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
199                    .take(100)
200                    .map(|s| SymbolInfo {
201                        name: s.name.clone(),
202                        file: s.file.clone(),
203                        kind: s.kind.clone(),
204                        start_line: s.start_line,
205                        end_line: s.end_line,
206                        is_exported: s.is_exported,
207                    })
208                    .collect()
209            }
210        }
211    }
212
213    /// Every symbol with its file + line span (unfiltered). Backend-agnostic
214    /// equivalent of iterating `ProjectIndex::symbols` — used by the call-graph
215    /// builder to attribute call sites to their enclosing symbol.
216    pub fn all_symbols(&self) -> Vec<SymbolInfo> {
217        match self {
218            GraphProvider::PropertyGraph(g) => g
219                .all_symbols()
220                .unwrap_or_default()
221                .into_iter()
222                .map(symbol_info_from_node)
223                .collect(),
224            GraphProvider::GraphIndex(i) => i
225                .symbols
226                .values()
227                .map(|s| SymbolInfo {
228                    name: s.name.clone(),
229                    file: s.file.clone(),
230                    kind: s.kind.clone(),
231                    start_line: s.start_line,
232                    end_line: s.end_line,
233                    is_exported: s.is_exported,
234                })
235                .collect(),
236        }
237    }
238
239    pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
240        match self {
241            GraphProvider::PropertyGraph(g) => {
242                // Keys are `rel_path::sym_name` (graph_index, see `mod.rs`). A
243                // file path never contains `::`, but a symbol name does for trait
244                // impls (`std::fmt::Display for T`). Split on the FIRST `::` so
245                // those names round-trip — `rsplitn` mangled them (#682.3).
246                let parts: Vec<&str> = key.splitn(2, "::").collect();
247                if parts.len() != 2 {
248                    return None;
249                }
250                let (file_path, sym_name) = (parts[0], parts[1]);
251                g.get_node_by_symbol(sym_name, file_path)
252                    .ok()
253                    .flatten()
254                    .map(symbol_info_from_node)
255            }
256            GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
257                name: s.name.clone(),
258                file: s.file.clone(),
259                kind: s.kind.clone(),
260                start_line: s.start_line,
261                end_line: s.end_line,
262                is_exported: s.is_exported,
263            }),
264        }
265    }
266
267    /// Resolve a stable `SymbolHandle` to
268    /// its current [`SymbolInfo`], robust to line drift. Resolution order:
269    ///
270    /// 1. Exact `(path, name)` — the unique `{file}::{name}` index key, so the
271    ///    common case is a single `O(1)` lookup that ignores the `@Lline` hint
272    ///    entirely (the symbol may have moved since the handle was emitted).
273    /// 2. Otherwise, same-file candidates whose name matches exactly or by its
274    ///    unqualified tail (`Config::load` ↔ `load`), ranked by exact-name
275    ///    first, then nearest line to the handle hint, then lowest line, then
276    ///    name — a total, deterministic order (#498).
277    ///
278    /// Returns `None` only when no symbol in that file plausibly matches. The
279    /// `@Lline` is never a hard requirement, so this is strictly more robust
280    /// than a brittle line-only reference.
281    pub fn find_symbol_by_handle(
282        &self,
283        handle: &crate::core::handle::SymbolHandle,
284    ) -> Option<SymbolInfo> {
285        let key = format!("{}::{}", handle.path, handle.name);
286        if let Some(sym) = self.get_symbol(&key) {
287            return Some(sym);
288        }
289
290        let tail = handle
291            .name
292            .rsplit("::")
293            .next()
294            .unwrap_or(handle.name.as_str());
295        let mut candidates: Vec<SymbolInfo> = self
296            .all_symbols()
297            .into_iter()
298            .filter(|s| s.file == handle.path)
299            .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
300            .collect();
301        if candidates.is_empty() {
302            return None;
303        }
304        candidates.sort_by(|a, b| {
305            let exact_a = u8::from(a.name != handle.name);
306            let exact_b = u8::from(b.name != handle.name);
307            exact_a
308                .cmp(&exact_b)
309                .then_with(|| {
310                    line_distance(a.start_line, handle.line)
311                        .cmp(&line_distance(b.start_line, handle.line))
312                })
313                .then_with(|| a.start_line.cmp(&b.start_line))
314                .then_with(|| a.name.cmp(&b.name))
315        });
316        candidates.into_iter().next()
317    }
318
319    pub fn edges(&self) -> Vec<EdgeInfo> {
320        match self {
321            // Normalize the raw property-graph edge-kind vocabulary back to the
322            // graph_index vocabulary every consumer speaks (notably `imports` →
323            // `import`, queried by impact/overview via `edges_by_kind("import")`).
324            // Without this the facade leaks `EdgeKind::as_str()` plurals and PG
325            // vs legacy backends would answer the same query differently (#696).
326            GraphProvider::PropertyGraph(g) => g
327                .all_edges_flat()
328                .unwrap_or_default()
329                .into_iter()
330                .map(|(from, to, kind, weight)| EdgeInfo {
331                    from,
332                    to,
333                    kind: index_edge_kind(&kind),
334                    weight,
335                })
336                .collect(),
337            GraphProvider::GraphIndex(i) => i
338                .edges
339                .iter()
340                .map(|e| EdgeInfo {
341                    from: e.from.clone(),
342                    to: e.to.clone(),
343                    kind: e.kind.clone(),
344                    weight: e.weight as f64,
345                })
346                .collect(),
347        }
348    }
349
350    pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
351        self.edges()
352            .into_iter()
353            .filter(|e| e.kind == kind)
354            .collect()
355    }
356
357    /// Every catalogued file as [`FileInfo`]. Backend-agnostic equivalent of
358    /// iterating `ProjectIndex::files` — used by stats/bootstrap consumers that
359    /// need per-file language + token counts, not just paths.
360    pub fn file_entries(&self) -> Vec<FileInfo> {
361        match self {
362            GraphProvider::PropertyGraph(_) => self
363                .file_paths()
364                .into_iter()
365                .filter_map(|p| self.get_file_entry(&p))
366                .collect(),
367            GraphProvider::GraphIndex(i) => i
368                .files
369                .values()
370                .map(|e| FileInfo {
371                    path: e.path.clone(),
372                    hash: e.hash.clone(),
373                    language: e.language.clone(),
374                    line_count: e.line_count,
375                    token_count: e.token_count,
376                    exports: e.exports.clone(),
377                    summary: e.summary.clone(),
378                })
379                .collect(),
380        }
381    }
382
383    pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
384        match self {
385            GraphProvider::PropertyGraph(g) => {
386                g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
387                    path: e.path,
388                    hash: e.hash,
389                    language: e.language,
390                    line_count: e.line_count,
391                    token_count: e.token_count,
392                    exports: e.exports,
393                    summary: e.summary,
394                })
395            }
396            GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
397                path: e.path.clone(),
398                hash: e.hash.clone(),
399                language: e.language.clone(),
400                line_count: e.line_count,
401                token_count: e.token_count,
402                exports: e.exports.clone(),
403                summary: e.summary.clone(),
404            }),
405        }
406    }
407
408    pub fn last_scan(&self) -> String {
409        match self {
410            GraphProvider::PropertyGraph(_) => String::new(),
411            GraphProvider::GraphIndex(i) => i.last_scan.clone(),
412        }
413    }
414
415    /// Reconstruct a full [`ProjectIndex`] from this provider — the inverse of
416    /// the graph_index→PG mirror
417    /// ([`populate_from_project_index`](super::property_graph::populate_from_project_index)).
418    /// Lets the
419    /// remaining legacy `ProjectIndex` consumers be sourced from the
420    /// PropertyGraph (parity-proven lossless, #682.3) so the redundant JSON
421    /// store can be retired (#696 phase C). For the GraphIndex backend it clones
422    /// the index it already holds.
423    pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
424        if let GraphProvider::GraphIndex(i) = self {
425            return i.clone();
426        }
427        let mut idx = ProjectIndex::new(project_root);
428        // Stamp `last_scan` from the graph's build time (graph.meta.json) so the
429        // TTL staleness check reflects the real build age, not this
430        // materialization moment (#696 C4). Content-based staleness still keys
431        // off the meta file's mtime independently.
432        if let Some(meta) = super::property_graph::load_meta(project_root)
433            && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
434        {
435            idx.last_scan = dt
436                .with_timezone(&chrono::Local)
437                .format("%Y-%m-%d %H:%M:%S")
438                .to_string();
439        }
440        for f in self.file_entries() {
441            idx.files.insert(
442                f.path.clone(),
443                graph_index::FileEntry {
444                    path: f.path,
445                    hash: f.hash,
446                    language: f.language,
447                    line_count: f.line_count,
448                    token_count: f.token_count,
449                    exports: f.exports,
450                    summary: f.summary,
451                },
452            );
453        }
454        for s in self.all_symbols() {
455            let key = format!("{}::{}", s.file, s.name);
456            idx.symbols.insert(
457                key,
458                graph_index::SymbolEntry {
459                    file: s.file,
460                    name: s.name,
461                    kind: s.kind,
462                    start_line: s.start_line,
463                    end_line: s.end_line,
464                    is_exported: s.is_exported,
465                },
466            );
467        }
468        for e in self.edges() {
469            // `edges()` already normalizes PG kinds to the graph_index
470            // vocabulary, so `e.kind` is ready to store verbatim.
471            idx.edges.push(graph_index::IndexEdge {
472                from: e.from,
473                to: e.to,
474                kind: e.kind,
475                weight: e.weight as f32,
476            });
477        }
478        idx.rebuild_interner();
479        idx
480    }
481
482    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
483        graph_index::ProjectIndex::index_dir(project_root)
484    }
485
486    /// Scored related files using multi-edge weights.
487    /// Falls back to unscored deps/dependents for GraphIndex backend.
488    pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
489        match self {
490            GraphProvider::PropertyGraph(g) => {
491                g.related_files(file_path, limit).unwrap_or_default()
492            }
493            GraphProvider::GraphIndex(_) => {
494                let mut result: Vec<(String, f64)> = Vec::new();
495                for dep in self.dependencies(file_path) {
496                    result.push((dep, 1.0));
497                }
498                for dep in self.dependents(file_path) {
499                    if !result.iter().any(|(p, _)| *p == dep) {
500                        result.push((dep, 0.5));
501                    }
502                }
503                result.truncate(limit);
504                result
505            }
506        }
507    }
508}
509
510/// Open whichever graph is already populated on disk, **without** triggering any
511/// build, plus a flag telling the caller the property graph still wants a
512/// (re)build. Prefers a fully-populated PropertyGraph and falls back to the
513/// in-memory graph_index extractor while the PG is not yet populated (first run
514/// or just after a rebuild), flagging `needs_build` so the caller can warm the
515/// PG for the next call (#696 phase D: the `legacy` backend escape hatch was
516/// retired once PG-only persistence proved lossless in #682.3).
517fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
518    let t0 = std::time::Instant::now();
519
520    let mut pg_provider = None;
521    let mut pg_populated = false;
522    if let Ok(pg) = CodeGraph::open(project_root) {
523        let nodes = pg.node_count().unwrap_or(0);
524        let edges = pg.edge_count().unwrap_or(0);
525        let file_cat = pg.file_catalog_count().unwrap_or(0);
526        pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
527        if pg_populated {
528            log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
529            return (
530                Some(OpenGraphProvider {
531                    source: GraphProviderSource::PropertyGraph,
532                    provider: GraphProvider::PropertyGraph(pg),
533                }),
534                false,
535            );
536        }
537        if nodes > 0 && file_cat > 0 {
538            pg_provider = Some(pg);
539        }
540    }
541
542    // PG is not fully populated: a (re)build would help the next call.
543    let needs_build = !pg_populated;
544
545    if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
546        let files = idx.files.len();
547        let edges = idx.edges.len();
548        if !idx.edges.is_empty() || !idx.files.is_empty() {
549            log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
550            return (
551                Some(OpenGraphProvider {
552                    source: GraphProviderSource::GraphIndex,
553                    provider: GraphProvider::GraphIndex(idx),
554                }),
555                needs_build,
556            );
557        }
558    }
559
560    if let Some(pg) = pg_provider {
561        let nodes = pg.node_count().unwrap_or(0);
562        log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
563        return (
564            Some(OpenGraphProvider {
565                source: GraphProviderSource::PropertyGraph,
566                provider: GraphProvider::PropertyGraph(pg),
567            }),
568            needs_build,
569        );
570    }
571
572    (None, needs_build)
573}
574
575/// Open an already-built graph, kicking off a one-shot background build when the
576/// property graph is not fully populated so the *next* call is fast. Returns
577/// `None` on this call when nothing is ready yet. Best-effort callers
578/// (dashboards, context gate, stats, `ctx_graph`) use this; callers that need a
579/// graph *right now* use [`open_or_build`], which builds synchronously instead.
580pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
581    let (existing, needs_build) = open_existing(project_root);
582    if needs_build {
583        trigger_lazy_graph_build(project_root);
584    }
585    existing
586}
587
588fn log_source_selection(
589    source: GraphProviderSource,
590    nodes: usize,
591    edges: usize,
592    start: std::time::Instant,
593) {
594    let elapsed_ms = start.elapsed().as_millis();
595    if std::env::var("LCTX_DEBUG").is_ok() {
596        eprintln!(
597            "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
598        );
599    }
600    let _ = (source, nodes, edges, elapsed_ms);
601}
602
603/// Triggers a background graph build once per process when the graph is empty.
604fn trigger_lazy_graph_build(project_root: &str) {
605    // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses
606    // its own tempdir). A detached, fire-and-forget build thread reads that global
607    // mid-flight and runs concurrently with test bodies that are otherwise
608    // serialized on `test_env_lock` — a source of graph-state concurrency, and the
609    // root of an intermittent macOS-only flake where a freshly-built index appeared
610    // empty to the asserting test. `open_or_build` has a synchronous fallback that
611    // fully covers tests, so skip the background build under `cfg!(test)`. Production
612    // (and integration tests, which run the lib normally) are unaffected.
613    if cfg!(test) {
614        return;
615    }
616    if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
617        return;
618    }
619    let root = Path::new(project_root);
620    // Both probes are TCC-guarded (#356): a non-existent/non-dir path has no
621    // markers, and a launchd-standalone process never stats under ~/Documents.
622    let is_project = crate::core::pathutil::has_project_marker(root)
623        || crate::core::pathutil::has_multi_repo_children(root);
624    if !is_project {
625        return;
626    }
627    // #682.2: build via the same reliable worker that builds the JSON index
628    // (which now mirrors into PG, backend-gated), instead of a dedicated
629    // fire-and-forget thread that silently died in short-lived processes.
630    super::index_orchestrator::ensure_all_background(project_root);
631}
632
633/// Build the property graph from the proven graph_index extractor (#682.1).
634///
635/// Loads the current [`ProjectIndex`] (or scans if absent) and mirrors it into
636/// the SQLite store — files + `file_catalog`, symbols, and structural edges —
637/// then stamps `graph.meta.json`. Sourcing PG from the mature extractor
638/// guarantees PG ⊇ graph_index (so a later backend flip cannot lose data) and
639/// populates the `file_catalog` that the `pg_populated` gate requires.
640///
641/// Synchronous and self-contained in `core` (no `tools` dependency), so callers
642/// can build reliably without the fire-and-forget caveat of the lazy trigger.
643pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
644    let index = super::index_orchestrator::try_load_graph_index(project_root)
645        .filter(|i| !i.files.is_empty())
646        .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
647    super::property_graph::mirror_index(project_root, &index)
648}
649
650/// Maximum time `open_or_build` will wait for a synchronous graph build before
651/// falling back to "graph unavailable" (#1018). Prevents blocking-pool
652/// exhaustion on large repos with cold indices, especially on Windows with AV.
653const BUILD_TIMEOUT_SECS: u64 = 30;
654
655/// Serialization gate: only one synchronous graph build at a time. Concurrent
656/// callers that cannot acquire the gate return immediately with `None` rather
657/// than queueing (which would exhaust the blocking pool). The background indexer
658/// picks up the build for them asynchronously.
659static BUILD_GATE: std::sync::LazyLock<std::sync::Mutex<()>> =
660    std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
661
662pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
663    if let (Some(p), _) = open_existing(project_root) {
664        return Some(p);
665    }
666
667    // Try to acquire the build gate without blocking. If another tool call is
668    // already building the graph, don't queue — trigger background build and
669    // return None so the caller gets a graceful "index building" message.
670    let Ok(_gate) = BUILD_GATE.try_lock() else {
671        tracing::info!(
672            "open_or_build: another build in progress for {project_root}; returning None"
673        );
674        trigger_lazy_graph_build(project_root);
675        return None;
676    };
677
678    // Run the potentially-slow `load_or_build` under a timeout thread so we
679    // don't block the calling tool handler for unbounded time (#1018).
680    let root_owned = project_root.to_string();
681    let (tx, rx) = std::sync::mpsc::sync_channel(1);
682    std::thread::spawn(move || {
683        let idx = super::graph_index::load_or_build(&root_owned);
684        let _ = tx.send(idx);
685    });
686
687    let timeout = std::time::Duration::from_secs(BUILD_TIMEOUT_SECS);
688    match rx.recv_timeout(timeout) {
689        Ok(idx) if !idx.files.is_empty() => Some(OpenGraphProvider {
690            source: GraphProviderSource::GraphIndex,
691            provider: GraphProvider::GraphIndex(idx),
692        }),
693        Ok(_) => None,
694        Err(_) => {
695            tracing::warn!(
696                "open_or_build: graph build timed out after {BUILD_TIMEOUT_SECS}s for {project_root}; \
697                 triggering background build"
698            );
699            trigger_lazy_graph_build(project_root);
700            None
701        }
702    }
703}
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn best_effort_prefers_graph_index_when_property_graph_empty() {
710        let _lock = crate::core::data_dir::test_env_lock();
711        let tmp = tempfile::tempdir().expect("tempdir");
712        let data = tmp.path().join("data");
713        std::fs::create_dir_all(&data).expect("mkdir data");
714        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
715
716        let project_root = tmp.path().join("proj");
717        std::fs::create_dir_all(&project_root).expect("mkdir proj");
718        let root = project_root.to_string_lossy().to_string();
719
720        let mut idx = ProjectIndex::new(&root);
721        idx.files.insert(
722            "src/main.rs".to_string(),
723            super::super::graph_index::FileEntry {
724                path: "src/main.rs".to_string(),
725                hash: "h".to_string(),
726                language: "rs".to_string(),
727                line_count: 1,
728                token_count: 1,
729                exports: vec![],
730                summary: String::new(),
731            },
732        );
733        idx.save().expect("save index");
734
735        let open = open_best_effort(&root).expect("open");
736        assert_eq!(open.source, GraphProviderSource::GraphIndex);
737
738        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
739    }
740
741    #[test]
742    fn best_effort_none_when_no_graphs() {
743        let _lock = crate::core::data_dir::test_env_lock();
744        let tmp = tempfile::tempdir().expect("tempdir");
745        let data = tmp.path().join("data");
746        std::fs::create_dir_all(&data).expect("mkdir data");
747        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
748
749        let project_root = tmp.path().join("proj");
750        std::fs::create_dir_all(&project_root).expect("mkdir proj");
751        let root = project_root.to_string_lossy().to_string();
752
753        let open = open_best_effort(&root);
754        assert!(open.is_none());
755
756        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
757    }
758
759    fn handle_provider() -> GraphProvider {
760        let mut idx = ProjectIndex::new("/test");
761        for (key, file, name, kind, start, end) in [
762            (
763                "src/lib.rs::Config",
764                "src/lib.rs",
765                "Config",
766                "struct",
767                5usize,
768                20usize,
769            ),
770            (
771                "src/lib.rs::Config::load",
772                "src/lib.rs",
773                "Config::load",
774                "method",
775                22,
776                35,
777            ),
778            ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
779        ] {
780            idx.symbols.insert(
781                key.to_string(),
782                graph_index::SymbolEntry {
783                    file: file.to_string(),
784                    name: name.to_string(),
785                    kind: kind.to_string(),
786                    start_line: start,
787                    end_line: end,
788                    is_exported: true,
789                },
790            );
791        }
792        GraphProvider::GraphIndex(idx)
793    }
794
795    #[test]
796    fn handle_resolves_exact_file_and_name() {
797        let gp = handle_provider();
798        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
799        let sym = gp.find_symbol_by_handle(&h).expect("resolves");
800        assert_eq!(sym.name, "Config::load");
801        assert_eq!(sym.start_line, 22);
802    }
803
804    #[test]
805    fn handle_resolves_after_line_drift() {
806        // Same (path, name) but a stale, drifted line — must still resolve to
807        // the current symbol at its real line (robust beyond line-only refs).
808        let gp = handle_provider();
809        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
810        let sym = gp
811            .find_symbol_by_handle(&h)
812            .expect("resolves despite drift");
813        assert_eq!(sym.start_line, 22);
814    }
815
816    #[test]
817    fn handle_resolves_by_unqualified_tail() {
818        // Handle carries only the unqualified tail `load`; the exact key misses,
819        // so the same-file tail match + line tiebreak recovers `Config::load`.
820        let gp = handle_provider();
821        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
822        let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
823        assert_eq!(sym.name, "Config::load");
824    }
825
826    #[test]
827    fn handle_unknown_file_returns_none() {
828        let gp = handle_provider();
829        let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
830        assert!(gp.find_symbol_by_handle(&h).is_none());
831    }
832
833    #[test]
834    fn pg_kind_filter_matches_precise_metadata_kind() {
835        // #889: a PG symbol's coarse `nodes.kind` is always `"symbol"`; the
836        // source kind (`struct`) lives in metadata. Filtering by kind="struct"
837        // must still find it (previously matched the coarse column → empty).
838        use super::super::property_graph::{Node, NodeKind};
839
840        let pg = CodeGraph::open_in_memory().unwrap();
841        pg.upsert_node(
842            &Node::symbol("State", "simplex/simplex.go", NodeKind::Symbol)
843                .with_lines(129, 140)
844                .with_metadata(r#"{"kind":"struct","exported":true}"#),
845        )
846        .unwrap();
847        let gp = GraphProvider::PropertyGraph(pg);
848
849        let hits = gp.find_symbols("State", None, Some("struct"));
850        assert_eq!(
851            hits.len(),
852            1,
853            "kind=struct must resolve the exported struct"
854        );
855        assert_eq!(hits[0].kind, "struct");
856        assert!(hits[0].is_exported);
857
858        // A non-matching kind still filters it out.
859        assert!(gp.find_symbols("State", None, Some("fn")).is_empty());
860    }
861
862    #[test]
863    fn parity_dependencies_both_stores_agree() {
864        use super::super::graph_index::{FileEntry, IndexEdge};
865        use super::super::property_graph::{Edge, EdgeKind, Node};
866
867        let pg = CodeGraph::open_in_memory().unwrap();
868        let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
869        let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
870        let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
871        pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
872            .unwrap();
873        pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
874            .unwrap();
875
876        let mut idx = ProjectIndex::new("/test");
877        for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
878            idx.files.insert(
879                name.to_string(),
880                FileEntry {
881                    path: name.to_string(),
882                    hash: "h".into(),
883                    language: "rs".into(),
884                    line_count: 1,
885                    token_count: 1,
886                    exports: vec![],
887                    summary: String::new(),
888                },
889            );
890        }
891        idx.edges.push(IndexEdge {
892            from: "src/a.rs".into(),
893            to: "src/b.rs".into(),
894            kind: "import".into(),
895            weight: 1.0,
896        });
897        idx.edges.push(IndexEdge {
898            from: "src/a.rs".into(),
899            to: "src/c.rs".into(),
900            kind: "import".into(),
901            weight: 1.0,
902        });
903
904        let pg_deps = GraphProvider::PropertyGraph(pg);
905        let gi_deps = GraphProvider::GraphIndex(idx);
906
907        let mut pg_result = pg_deps.dependencies("src/a.rs");
908        let mut gi_result = gi_deps.dependencies("src/a.rs");
909        pg_result.sort();
910        gi_result.sort();
911
912        assert_eq!(
913            pg_result, gi_result,
914            "Import edges must match between PG and GraphIndex"
915        );
916
917        let mut pg_dependents = pg_deps.dependents("src/b.rs");
918        let mut gi_dependents = gi_deps.dependents("src/b.rs");
919        pg_dependents.sort();
920        gi_dependents.sort();
921        assert_eq!(
922            pg_dependents, gi_dependents,
923            "Dependents must match between PG and GraphIndex"
924        );
925    }
926
927    /// Round-trip guard for #696 C1: graph_index → PG (mirror) → graph_index
928    /// (materialize) must preserve files (full catalog), symbols (incl. the
929    /// precise `kind` + `is_exported`, which live in node metadata) and import
930    /// edges. Proves the PropertyGraph can be the sole source for the remaining
931    /// legacy `ProjectIndex` consumers — the prerequisite for retiring the JSON
932    /// store.
933    #[test]
934    fn materialize_project_index_round_trips_losslessly() {
935        use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
936        use super::super::property_graph::populate_from_project_index;
937
938        let mut a = ProjectIndex::new("/test");
939        a.files.insert(
940            "src/a.rs".to_string(),
941            FileEntry {
942                path: "src/a.rs".to_string(),
943                hash: "hash-a".to_string(),
944                language: "rs".to_string(),
945                line_count: 42,
946                token_count: 137,
947                exports: vec!["Foo".to_string()],
948                summary: "module a".to_string(),
949            },
950        );
951        a.files.insert(
952            "src/b.rs".to_string(),
953            FileEntry {
954                path: "src/b.rs".to_string(),
955                hash: "hash-b".to_string(),
956                language: "rs".to_string(),
957                line_count: 7,
958                token_count: 19,
959                exports: vec![],
960                summary: String::new(),
961            },
962        );
963        // Two symbols with DIFFERENT kinds and export flags — the fields most at
964        // risk of being flattened by the coarse property-graph `NodeKind`.
965        a.symbols.insert(
966            "src/a.rs::Foo".to_string(),
967            SymbolEntry {
968                file: "src/a.rs".to_string(),
969                name: "Foo".to_string(),
970                kind: "struct".to_string(),
971                start_line: 1,
972                end_line: 9,
973                is_exported: true,
974            },
975        );
976        a.symbols.insert(
977            "src/b.rs::helper".to_string(),
978            SymbolEntry {
979                file: "src/b.rs".to_string(),
980                name: "helper".to_string(),
981                kind: "function".to_string(),
982                start_line: 3,
983                end_line: 6,
984                is_exported: false,
985            },
986        );
987        a.edges.push(IndexEdge {
988            from: "src/b.rs".to_string(),
989            to: "src/a.rs".to_string(),
990            kind: "import".to_string(),
991            weight: 1.0,
992        });
993
994        let pg = CodeGraph::open_in_memory().unwrap();
995        populate_from_project_index(&pg, &a).unwrap();
996        let provider = GraphProvider::PropertyGraph(pg);
997        let b = provider.materialize_project_index("/test");
998
999        // Files: inventory + every catalog field survive.
1000        let mut a_files: Vec<&String> = a.files.keys().collect();
1001        let mut b_files: Vec<&String> = b.files.keys().collect();
1002        a_files.sort();
1003        b_files.sort();
1004        assert_eq!(a_files, b_files, "file inventory must round-trip");
1005        for (path, fa) in &a.files {
1006            let fb = b.files.get(path).expect("file present after round trip");
1007            assert_eq!(fa.hash, fb.hash, "hash {path}");
1008            assert_eq!(fa.language, fb.language, "language {path}");
1009            assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
1010            assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
1011            assert_eq!(fa.exports, fb.exports, "exports {path}");
1012            assert_eq!(fa.summary, fb.summary, "summary {path}");
1013        }
1014
1015        // Symbols: keys + spans + the metadata-carried kind/export flag survive.
1016        let mut a_syms: Vec<&String> = a.symbols.keys().collect();
1017        let mut b_syms: Vec<&String> = b.symbols.keys().collect();
1018        a_syms.sort();
1019        b_syms.sort();
1020        assert_eq!(a_syms, b_syms, "symbol table must round-trip");
1021        for (key, sa) in &a.symbols {
1022            let sb = b.symbols.get(key).expect("symbol present after round trip");
1023            assert_eq!(sa.name, sb.name, "name {key}");
1024            assert_eq!(sa.file, sb.file, "file {key}");
1025            assert_eq!(sa.kind, sb.kind, "kind {key}");
1026            assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
1027            assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
1028            assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
1029        }
1030
1031        // Structural edges: the import edge survives (PG may enrich, never lose).
1032        assert!(
1033            b.edges
1034                .iter()
1035                .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
1036            "import edge must round-trip; got {:?}",
1037            b.edges
1038        );
1039    }
1040}