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                .find_symbols(name, file_filter, kind_filter)
179                .unwrap_or_default()
180                .into_iter()
181                .map(symbol_info_from_node)
182                .collect(),
183            GraphProvider::GraphIndex(i) => {
184                let name_lower = name.to_lowercase();
185                i.symbols
186                    .values()
187                    .filter(|s| s.name.to_lowercase().contains(&name_lower))
188                    .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
189                    .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
190                    .take(100)
191                    .map(|s| SymbolInfo {
192                        name: s.name.clone(),
193                        file: s.file.clone(),
194                        kind: s.kind.clone(),
195                        start_line: s.start_line,
196                        end_line: s.end_line,
197                        is_exported: s.is_exported,
198                    })
199                    .collect()
200            }
201        }
202    }
203
204    /// Every symbol with its file + line span (unfiltered). Backend-agnostic
205    /// equivalent of iterating `ProjectIndex::symbols` — used by the call-graph
206    /// builder to attribute call sites to their enclosing symbol.
207    pub fn all_symbols(&self) -> Vec<SymbolInfo> {
208        match self {
209            GraphProvider::PropertyGraph(g) => g
210                .all_symbols()
211                .unwrap_or_default()
212                .into_iter()
213                .map(symbol_info_from_node)
214                .collect(),
215            GraphProvider::GraphIndex(i) => i
216                .symbols
217                .values()
218                .map(|s| SymbolInfo {
219                    name: s.name.clone(),
220                    file: s.file.clone(),
221                    kind: s.kind.clone(),
222                    start_line: s.start_line,
223                    end_line: s.end_line,
224                    is_exported: s.is_exported,
225                })
226                .collect(),
227        }
228    }
229
230    pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
231        match self {
232            GraphProvider::PropertyGraph(g) => {
233                // Keys are `rel_path::sym_name` (graph_index, see `mod.rs`). A
234                // file path never contains `::`, but a symbol name does for trait
235                // impls (`std::fmt::Display for T`). Split on the FIRST `::` so
236                // those names round-trip — `rsplitn` mangled them (#682.3).
237                let parts: Vec<&str> = key.splitn(2, "::").collect();
238                if parts.len() != 2 {
239                    return None;
240                }
241                let (file_path, sym_name) = (parts[0], parts[1]);
242                g.get_node_by_symbol(sym_name, file_path)
243                    .ok()
244                    .flatten()
245                    .map(symbol_info_from_node)
246            }
247            GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
248                name: s.name.clone(),
249                file: s.file.clone(),
250                kind: s.kind.clone(),
251                start_line: s.start_line,
252                end_line: s.end_line,
253                is_exported: s.is_exported,
254            }),
255        }
256    }
257
258    /// Resolve a stable [`SymbolHandle`](crate::core::handle::SymbolHandle) to
259    /// its current [`SymbolInfo`], robust to line drift. Resolution order:
260    ///
261    /// 1. Exact `(path, name)` — the unique `{file}::{name}` index key, so the
262    ///    common case is a single `O(1)` lookup that ignores the `@Lline` hint
263    ///    entirely (the symbol may have moved since the handle was emitted).
264    /// 2. Otherwise, same-file candidates whose name matches exactly or by its
265    ///    unqualified tail (`Config::load` ↔ `load`), ranked by exact-name
266    ///    first, then nearest line to the handle hint, then lowest line, then
267    ///    name — a total, deterministic order (#498).
268    ///
269    /// Returns `None` only when no symbol in that file plausibly matches. The
270    /// `@Lline` is never a hard requirement, so this is strictly more robust
271    /// than a brittle line-only reference.
272    pub fn find_symbol_by_handle(
273        &self,
274        handle: &crate::core::handle::SymbolHandle,
275    ) -> Option<SymbolInfo> {
276        let key = format!("{}::{}", handle.path, handle.name);
277        if let Some(sym) = self.get_symbol(&key) {
278            return Some(sym);
279        }
280
281        let tail = handle
282            .name
283            .rsplit("::")
284            .next()
285            .unwrap_or(handle.name.as_str());
286        let mut candidates: Vec<SymbolInfo> = self
287            .all_symbols()
288            .into_iter()
289            .filter(|s| s.file == handle.path)
290            .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
291            .collect();
292        if candidates.is_empty() {
293            return None;
294        }
295        candidates.sort_by(|a, b| {
296            let exact_a = u8::from(a.name != handle.name);
297            let exact_b = u8::from(b.name != handle.name);
298            exact_a
299                .cmp(&exact_b)
300                .then_with(|| {
301                    line_distance(a.start_line, handle.line)
302                        .cmp(&line_distance(b.start_line, handle.line))
303                })
304                .then_with(|| a.start_line.cmp(&b.start_line))
305                .then_with(|| a.name.cmp(&b.name))
306        });
307        candidates.into_iter().next()
308    }
309
310    pub fn edges(&self) -> Vec<EdgeInfo> {
311        match self {
312            // Normalize the raw property-graph edge-kind vocabulary back to the
313            // graph_index vocabulary every consumer speaks (notably `imports` →
314            // `import`, queried by impact/overview via `edges_by_kind("import")`).
315            // Without this the facade leaks `EdgeKind::as_str()` plurals and PG
316            // vs legacy backends would answer the same query differently (#696).
317            GraphProvider::PropertyGraph(g) => g
318                .all_edges_flat()
319                .unwrap_or_default()
320                .into_iter()
321                .map(|(from, to, kind, weight)| EdgeInfo {
322                    from,
323                    to,
324                    kind: index_edge_kind(&kind),
325                    weight,
326                })
327                .collect(),
328            GraphProvider::GraphIndex(i) => i
329                .edges
330                .iter()
331                .map(|e| EdgeInfo {
332                    from: e.from.clone(),
333                    to: e.to.clone(),
334                    kind: e.kind.clone(),
335                    weight: e.weight as f64,
336                })
337                .collect(),
338        }
339    }
340
341    pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
342        self.edges()
343            .into_iter()
344            .filter(|e| e.kind == kind)
345            .collect()
346    }
347
348    /// Every catalogued file as [`FileInfo`]. Backend-agnostic equivalent of
349    /// iterating `ProjectIndex::files` — used by stats/bootstrap consumers that
350    /// need per-file language + token counts, not just paths.
351    pub fn file_entries(&self) -> Vec<FileInfo> {
352        match self {
353            GraphProvider::PropertyGraph(_) => self
354                .file_paths()
355                .into_iter()
356                .filter_map(|p| self.get_file_entry(&p))
357                .collect(),
358            GraphProvider::GraphIndex(i) => i
359                .files
360                .values()
361                .map(|e| FileInfo {
362                    path: e.path.clone(),
363                    hash: e.hash.clone(),
364                    language: e.language.clone(),
365                    line_count: e.line_count,
366                    token_count: e.token_count,
367                    exports: e.exports.clone(),
368                    summary: e.summary.clone(),
369                })
370                .collect(),
371        }
372    }
373
374    pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
375        match self {
376            GraphProvider::PropertyGraph(g) => {
377                g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
378                    path: e.path,
379                    hash: e.hash,
380                    language: e.language,
381                    line_count: e.line_count,
382                    token_count: e.token_count,
383                    exports: e.exports,
384                    summary: e.summary,
385                })
386            }
387            GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
388                path: e.path.clone(),
389                hash: e.hash.clone(),
390                language: e.language.clone(),
391                line_count: e.line_count,
392                token_count: e.token_count,
393                exports: e.exports.clone(),
394                summary: e.summary.clone(),
395            }),
396        }
397    }
398
399    pub fn last_scan(&self) -> String {
400        match self {
401            GraphProvider::PropertyGraph(_) => String::new(),
402            GraphProvider::GraphIndex(i) => i.last_scan.clone(),
403        }
404    }
405
406    /// Reconstruct a full [`ProjectIndex`] from this provider — the inverse of
407    /// the graph_index→PG mirror
408    /// ([`populate_from_project_index`](super::property_graph::populate_from_project_index)).
409    /// Lets the
410    /// remaining legacy `ProjectIndex` consumers be sourced from the
411    /// PropertyGraph (parity-proven lossless, #682.3) so the redundant JSON
412    /// store can be retired (#696 phase C). For the GraphIndex backend it clones
413    /// the index it already holds.
414    pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
415        if let GraphProvider::GraphIndex(i) = self {
416            return i.clone();
417        }
418        let mut idx = ProjectIndex::new(project_root);
419        // Stamp `last_scan` from the graph's build time (graph.meta.json) so the
420        // TTL staleness check reflects the real build age, not this
421        // materialization moment (#696 C4). Content-based staleness still keys
422        // off the meta file's mtime independently.
423        if let Some(meta) = super::property_graph::load_meta(project_root)
424            && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
425        {
426            idx.last_scan = dt
427                .with_timezone(&chrono::Local)
428                .format("%Y-%m-%d %H:%M:%S")
429                .to_string();
430        }
431        for f in self.file_entries() {
432            idx.files.insert(
433                f.path.clone(),
434                graph_index::FileEntry {
435                    path: f.path,
436                    hash: f.hash,
437                    language: f.language,
438                    line_count: f.line_count,
439                    token_count: f.token_count,
440                    exports: f.exports,
441                    summary: f.summary,
442                },
443            );
444        }
445        for s in self.all_symbols() {
446            let key = format!("{}::{}", s.file, s.name);
447            idx.symbols.insert(
448                key,
449                graph_index::SymbolEntry {
450                    file: s.file,
451                    name: s.name,
452                    kind: s.kind,
453                    start_line: s.start_line,
454                    end_line: s.end_line,
455                    is_exported: s.is_exported,
456                },
457            );
458        }
459        for e in self.edges() {
460            // `edges()` already normalizes PG kinds to the graph_index
461            // vocabulary, so `e.kind` is ready to store verbatim.
462            idx.edges.push(graph_index::IndexEdge {
463                from: e.from,
464                to: e.to,
465                kind: e.kind,
466                weight: e.weight as f32,
467            });
468        }
469        idx
470    }
471
472    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
473        graph_index::ProjectIndex::index_dir(project_root)
474    }
475
476    /// Scored related files using multi-edge weights.
477    /// Falls back to unscored deps/dependents for GraphIndex backend.
478    pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
479        match self {
480            GraphProvider::PropertyGraph(g) => {
481                g.related_files(file_path, limit).unwrap_or_default()
482            }
483            GraphProvider::GraphIndex(_) => {
484                let mut result: Vec<(String, f64)> = Vec::new();
485                for dep in self.dependencies(file_path) {
486                    result.push((dep, 1.0));
487                }
488                for dep in self.dependents(file_path) {
489                    if !result.iter().any(|(p, _)| *p == dep) {
490                        result.push((dep, 0.5));
491                    }
492                }
493                result.truncate(limit);
494                result
495            }
496        }
497    }
498}
499
500/// Open whichever graph is already populated on disk, **without** triggering any
501/// build, plus a flag telling the caller the property graph still wants a
502/// (re)build. Prefers a fully-populated PropertyGraph and falls back to the
503/// in-memory graph_index extractor while the PG is not yet populated (first run
504/// or just after a rebuild), flagging `needs_build` so the caller can warm the
505/// PG for the next call (#696 phase D: the `legacy` backend escape hatch was
506/// retired once PG-only persistence proved lossless in #682.3).
507fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
508    let t0 = std::time::Instant::now();
509
510    let mut pg_provider = None;
511    let mut pg_populated = false;
512    if let Ok(pg) = CodeGraph::open(project_root) {
513        let nodes = pg.node_count().unwrap_or(0);
514        let edges = pg.edge_count().unwrap_or(0);
515        let file_cat = pg.file_catalog_count().unwrap_or(0);
516        pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
517        if pg_populated {
518            log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
519            return (
520                Some(OpenGraphProvider {
521                    source: GraphProviderSource::PropertyGraph,
522                    provider: GraphProvider::PropertyGraph(pg),
523                }),
524                false,
525            );
526        }
527        if nodes > 0 && file_cat > 0 {
528            pg_provider = Some(pg);
529        }
530    }
531
532    // PG is not fully populated: a (re)build would help the next call.
533    let needs_build = !pg_populated;
534
535    if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
536        let files = idx.files.len();
537        let edges = idx.edges.len();
538        if !idx.edges.is_empty() || !idx.files.is_empty() {
539            log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
540            return (
541                Some(OpenGraphProvider {
542                    source: GraphProviderSource::GraphIndex,
543                    provider: GraphProvider::GraphIndex(idx),
544                }),
545                needs_build,
546            );
547        }
548    }
549
550    if let Some(pg) = pg_provider {
551        let nodes = pg.node_count().unwrap_or(0);
552        log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
553        return (
554            Some(OpenGraphProvider {
555                source: GraphProviderSource::PropertyGraph,
556                provider: GraphProvider::PropertyGraph(pg),
557            }),
558            needs_build,
559        );
560    }
561
562    (None, needs_build)
563}
564
565/// Open an already-built graph, kicking off a one-shot background build when the
566/// property graph is not fully populated so the *next* call is fast. Returns
567/// `None` on this call when nothing is ready yet. Best-effort callers
568/// (dashboards, context gate, stats, `ctx_graph`) use this; callers that need a
569/// graph *right now* use [`open_or_build`], which builds synchronously instead.
570pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
571    let (existing, needs_build) = open_existing(project_root);
572    if needs_build {
573        trigger_lazy_graph_build(project_root);
574    }
575    existing
576}
577
578fn log_source_selection(
579    source: GraphProviderSource,
580    nodes: usize,
581    edges: usize,
582    start: std::time::Instant,
583) {
584    let elapsed_ms = start.elapsed().as_millis();
585    if std::env::var("LCTX_DEBUG").is_ok() {
586        eprintln!(
587            "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
588        );
589    }
590    let _ = (source, nodes, edges, elapsed_ms);
591}
592
593/// Triggers a background graph build once per process when the graph is empty.
594fn trigger_lazy_graph_build(project_root: &str) {
595    // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses
596    // its own tempdir). A detached, fire-and-forget build thread reads that global
597    // mid-flight and runs concurrently with the otherwise-serial (`--test-threads=1`)
598    // test bodies — the one source of graph-state concurrency in the suite, and the
599    // root of an intermittent macOS-only flake where a freshly-built index appeared
600    // empty to the asserting test. `open_or_build` has a synchronous fallback that
601    // fully covers tests, so skip the background build under `cfg!(test)`. Production
602    // (and integration tests, which run the lib normally) are unaffected.
603    if cfg!(test) {
604        return;
605    }
606    if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
607        return;
608    }
609    let root = Path::new(project_root);
610    // Both probes are TCC-guarded (#356): a non-existent/non-dir path has no
611    // markers, and a launchd-standalone process never stats under ~/Documents.
612    let is_project = crate::core::pathutil::has_project_marker(root)
613        || crate::core::pathutil::has_multi_repo_children(root);
614    if !is_project {
615        return;
616    }
617    // #682.2: build via the same reliable worker that builds the JSON index
618    // (which now mirrors into PG, backend-gated), instead of a dedicated
619    // fire-and-forget thread that silently died in short-lived processes.
620    super::index_orchestrator::ensure_all_background(project_root);
621}
622
623/// Build the property graph from the proven graph_index extractor (#682.1).
624///
625/// Loads the current [`ProjectIndex`] (or scans if absent) and mirrors it into
626/// the SQLite store — files + `file_catalog`, symbols, and structural edges —
627/// then stamps `graph.meta.json`. Sourcing PG from the mature extractor
628/// guarantees PG ⊇ graph_index (so a later backend flip cannot lose data) and
629/// populates the `file_catalog` that the `pg_populated` gate requires.
630///
631/// Synchronous and self-contained in `core` (no `tools` dependency), so callers
632/// can build reliably without the fire-and-forget caveat of the lazy trigger.
633pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
634    let index = super::index_orchestrator::try_load_graph_index(project_root)
635        .filter(|i| !i.files.is_empty())
636        .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
637    super::property_graph::mirror_index(project_root, &index)
638}
639
640pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
641    // Open via `open_existing` (not `open_best_effort`): we build synchronously
642    // below, so we must NOT spawn the background indexer. Its worker holds the
643    // graph-index file lock, which would starve the synchronous scan into
644    // returning an empty index — the "No graph available" first-call regression
645    // after the PropertyGraph default flip (#695/#682.2).
646    if let (Some(p), _) = open_existing(project_root) {
647        return Some(p);
648    }
649    let idx = super::graph_index::load_or_build(project_root);
650    if idx.files.is_empty() {
651        return None;
652    }
653    Some(OpenGraphProvider {
654        source: GraphProviderSource::GraphIndex,
655        provider: GraphProvider::GraphIndex(idx),
656    })
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn best_effort_prefers_graph_index_when_property_graph_empty() {
665        let _lock = crate::core::data_dir::test_env_lock();
666        let tmp = tempfile::tempdir().expect("tempdir");
667        let data = tmp.path().join("data");
668        std::fs::create_dir_all(&data).expect("mkdir data");
669        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
670
671        let project_root = tmp.path().join("proj");
672        std::fs::create_dir_all(&project_root).expect("mkdir proj");
673        let root = project_root.to_string_lossy().to_string();
674
675        let mut idx = ProjectIndex::new(&root);
676        idx.files.insert(
677            "src/main.rs".to_string(),
678            super::super::graph_index::FileEntry {
679                path: "src/main.rs".to_string(),
680                hash: "h".to_string(),
681                language: "rs".to_string(),
682                line_count: 1,
683                token_count: 1,
684                exports: vec![],
685                summary: String::new(),
686            },
687        );
688        idx.save().expect("save index");
689
690        let open = open_best_effort(&root).expect("open");
691        assert_eq!(open.source, GraphProviderSource::GraphIndex);
692
693        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
694    }
695
696    #[test]
697    fn best_effort_none_when_no_graphs() {
698        let _lock = crate::core::data_dir::test_env_lock();
699        let tmp = tempfile::tempdir().expect("tempdir");
700        let data = tmp.path().join("data");
701        std::fs::create_dir_all(&data).expect("mkdir data");
702        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
703
704        let project_root = tmp.path().join("proj");
705        std::fs::create_dir_all(&project_root).expect("mkdir proj");
706        let root = project_root.to_string_lossy().to_string();
707
708        let open = open_best_effort(&root);
709        assert!(open.is_none());
710
711        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
712    }
713
714    fn handle_provider() -> GraphProvider {
715        let mut idx = ProjectIndex::new("/test");
716        for (key, file, name, kind, start, end) in [
717            (
718                "src/lib.rs::Config",
719                "src/lib.rs",
720                "Config",
721                "struct",
722                5usize,
723                20usize,
724            ),
725            (
726                "src/lib.rs::Config::load",
727                "src/lib.rs",
728                "Config::load",
729                "method",
730                22,
731                35,
732            ),
733            ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
734        ] {
735            idx.symbols.insert(
736                key.to_string(),
737                graph_index::SymbolEntry {
738                    file: file.to_string(),
739                    name: name.to_string(),
740                    kind: kind.to_string(),
741                    start_line: start,
742                    end_line: end,
743                    is_exported: true,
744                },
745            );
746        }
747        GraphProvider::GraphIndex(idx)
748    }
749
750    #[test]
751    fn handle_resolves_exact_file_and_name() {
752        let gp = handle_provider();
753        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
754        let sym = gp.find_symbol_by_handle(&h).expect("resolves");
755        assert_eq!(sym.name, "Config::load");
756        assert_eq!(sym.start_line, 22);
757    }
758
759    #[test]
760    fn handle_resolves_after_line_drift() {
761        // Same (path, name) but a stale, drifted line — must still resolve to
762        // the current symbol at its real line (robust beyond line-only refs).
763        let gp = handle_provider();
764        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
765        let sym = gp
766            .find_symbol_by_handle(&h)
767            .expect("resolves despite drift");
768        assert_eq!(sym.start_line, 22);
769    }
770
771    #[test]
772    fn handle_resolves_by_unqualified_tail() {
773        // Handle carries only the unqualified tail `load`; the exact key misses,
774        // so the same-file tail match + line tiebreak recovers `Config::load`.
775        let gp = handle_provider();
776        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
777        let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
778        assert_eq!(sym.name, "Config::load");
779    }
780
781    #[test]
782    fn handle_unknown_file_returns_none() {
783        let gp = handle_provider();
784        let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
785        assert!(gp.find_symbol_by_handle(&h).is_none());
786    }
787
788    #[test]
789    fn parity_dependencies_both_stores_agree() {
790        use super::super::graph_index::{FileEntry, IndexEdge};
791        use super::super::property_graph::{Edge, EdgeKind, Node};
792
793        let pg = CodeGraph::open_in_memory().unwrap();
794        let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
795        let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
796        let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
797        pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
798            .unwrap();
799        pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
800            .unwrap();
801
802        let mut idx = ProjectIndex::new("/test");
803        for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
804            idx.files.insert(
805                name.to_string(),
806                FileEntry {
807                    path: name.to_string(),
808                    hash: "h".into(),
809                    language: "rs".into(),
810                    line_count: 1,
811                    token_count: 1,
812                    exports: vec![],
813                    summary: String::new(),
814                },
815            );
816        }
817        idx.edges.push(IndexEdge {
818            from: "src/a.rs".into(),
819            to: "src/b.rs".into(),
820            kind: "import".into(),
821            weight: 1.0,
822        });
823        idx.edges.push(IndexEdge {
824            from: "src/a.rs".into(),
825            to: "src/c.rs".into(),
826            kind: "import".into(),
827            weight: 1.0,
828        });
829
830        let pg_deps = GraphProvider::PropertyGraph(pg);
831        let gi_deps = GraphProvider::GraphIndex(idx);
832
833        let mut pg_result = pg_deps.dependencies("src/a.rs");
834        let mut gi_result = gi_deps.dependencies("src/a.rs");
835        pg_result.sort();
836        gi_result.sort();
837
838        assert_eq!(
839            pg_result, gi_result,
840            "Import edges must match between PG and GraphIndex"
841        );
842
843        let mut pg_dependents = pg_deps.dependents("src/b.rs");
844        let mut gi_dependents = gi_deps.dependents("src/b.rs");
845        pg_dependents.sort();
846        gi_dependents.sort();
847        assert_eq!(
848            pg_dependents, gi_dependents,
849            "Dependents must match between PG and GraphIndex"
850        );
851    }
852
853    /// Round-trip guard for #696 C1: graph_index → PG (mirror) → graph_index
854    /// (materialize) must preserve files (full catalog), symbols (incl. the
855    /// precise `kind` + `is_exported`, which live in node metadata) and import
856    /// edges. Proves the PropertyGraph can be the sole source for the remaining
857    /// legacy `ProjectIndex` consumers — the prerequisite for retiring the JSON
858    /// store.
859    #[test]
860    fn materialize_project_index_round_trips_losslessly() {
861        use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
862        use super::super::property_graph::populate_from_project_index;
863
864        let mut a = ProjectIndex::new("/test");
865        a.files.insert(
866            "src/a.rs".to_string(),
867            FileEntry {
868                path: "src/a.rs".to_string(),
869                hash: "hash-a".to_string(),
870                language: "rs".to_string(),
871                line_count: 42,
872                token_count: 137,
873                exports: vec!["Foo".to_string()],
874                summary: "module a".to_string(),
875            },
876        );
877        a.files.insert(
878            "src/b.rs".to_string(),
879            FileEntry {
880                path: "src/b.rs".to_string(),
881                hash: "hash-b".to_string(),
882                language: "rs".to_string(),
883                line_count: 7,
884                token_count: 19,
885                exports: vec![],
886                summary: String::new(),
887            },
888        );
889        // Two symbols with DIFFERENT kinds and export flags — the fields most at
890        // risk of being flattened by the coarse property-graph `NodeKind`.
891        a.symbols.insert(
892            "src/a.rs::Foo".to_string(),
893            SymbolEntry {
894                file: "src/a.rs".to_string(),
895                name: "Foo".to_string(),
896                kind: "struct".to_string(),
897                start_line: 1,
898                end_line: 9,
899                is_exported: true,
900            },
901        );
902        a.symbols.insert(
903            "src/b.rs::helper".to_string(),
904            SymbolEntry {
905                file: "src/b.rs".to_string(),
906                name: "helper".to_string(),
907                kind: "function".to_string(),
908                start_line: 3,
909                end_line: 6,
910                is_exported: false,
911            },
912        );
913        a.edges.push(IndexEdge {
914            from: "src/b.rs".to_string(),
915            to: "src/a.rs".to_string(),
916            kind: "import".to_string(),
917            weight: 1.0,
918        });
919
920        let pg = CodeGraph::open_in_memory().unwrap();
921        populate_from_project_index(&pg, &a).unwrap();
922        let provider = GraphProvider::PropertyGraph(pg);
923        let b = provider.materialize_project_index("/test");
924
925        // Files: inventory + every catalog field survive.
926        let mut a_files: Vec<&String> = a.files.keys().collect();
927        let mut b_files: Vec<&String> = b.files.keys().collect();
928        a_files.sort();
929        b_files.sort();
930        assert_eq!(a_files, b_files, "file inventory must round-trip");
931        for (path, fa) in &a.files {
932            let fb = b.files.get(path).expect("file present after round trip");
933            assert_eq!(fa.hash, fb.hash, "hash {path}");
934            assert_eq!(fa.language, fb.language, "language {path}");
935            assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
936            assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
937            assert_eq!(fa.exports, fb.exports, "exports {path}");
938            assert_eq!(fa.summary, fb.summary, "summary {path}");
939        }
940
941        // Symbols: keys + spans + the metadata-carried kind/export flag survive.
942        let mut a_syms: Vec<&String> = a.symbols.keys().collect();
943        let mut b_syms: Vec<&String> = b.symbols.keys().collect();
944        a_syms.sort();
945        b_syms.sort();
946        assert_eq!(a_syms, b_syms, "symbol table must round-trip");
947        for (key, sa) in &a.symbols {
948            let sb = b.symbols.get(key).expect("symbol present after round trip");
949            assert_eq!(sa.name, sb.name, "name {key}");
950            assert_eq!(sa.file, sb.file, "file {key}");
951            assert_eq!(sa.kind, sb.kind, "kind {key}");
952            assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
953            assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
954            assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
955        }
956
957        // Structural edges: the import edge survives (PG may enrich, never lose).
958        assert!(
959            b.edges
960                .iter()
961                .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
962            "import edge must round-trip; got {:?}",
963            b.edges
964        );
965    }
966}