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/// Convert a property-graph symbol [`Node`] into a backend-agnostic
34/// [`SymbolInfo`], recovering the precise source `kind` and export flag from the
35/// node metadata (the `Node` itself only carries a coarse `NodeKind`). Single
36/// source of truth for the three facade methods that surface PG symbols, so a
37/// materialized `ProjectIndex` is lossless (#696 C1).
38fn symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
39    let (meta_kind, meta_exported) =
40        super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
41    SymbolInfo {
42        kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
43        is_exported: meta_exported.unwrap_or(true),
44        name: n.name,
45        file: n.file_path,
46        start_line: n.line_start.unwrap_or(0),
47        end_line: n.line_end.unwrap_or(0),
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct EdgeInfo {
53    pub from: String,
54    pub to: String,
55    pub kind: String,
56    pub weight: f64,
57}
58
59#[derive(Debug, Clone)]
60pub struct FileInfo {
61    pub path: String,
62    pub hash: String,
63    pub language: String,
64    pub line_count: usize,
65    pub token_count: usize,
66    pub exports: Vec<String>,
67    pub summary: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum GraphProviderSource {
72    PropertyGraph,
73    GraphIndex,
74}
75
76pub enum GraphProvider {
77    PropertyGraph(CodeGraph),
78    GraphIndex(ProjectIndex),
79}
80
81pub struct OpenGraphProvider {
82    pub source: GraphProviderSource,
83    pub provider: GraphProvider,
84}
85
86impl GraphProvider {
87    pub fn node_count(&self) -> Option<usize> {
88        match self {
89            GraphProvider::PropertyGraph(g) => g.node_count().ok(),
90            GraphProvider::GraphIndex(i) => Some(i.file_count()),
91        }
92    }
93
94    pub fn edge_count(&self) -> Option<usize> {
95        match self {
96            GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
97            GraphProvider::GraphIndex(i) => Some(i.edge_count()),
98        }
99    }
100
101    pub fn dependencies(&self, file_path: &str) -> Vec<String> {
102        match self {
103            GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
104            GraphProvider::GraphIndex(i) => i
105                .edges
106                .iter()
107                .filter(|e| e.kind == "import" && e.from == file_path)
108                .map(|e| e.to.clone())
109                .collect(),
110        }
111    }
112
113    pub fn dependents(&self, file_path: &str) -> Vec<String> {
114        match self {
115            GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
116            GraphProvider::GraphIndex(i) => i
117                .edges
118                .iter()
119                .filter(|e| e.kind == "import" && e.to == file_path)
120                .map(|e| e.from.clone())
121                .collect(),
122        }
123    }
124
125    pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
126        match self {
127            GraphProvider::PropertyGraph(g) => g
128                .impact_analysis(file_path, depth)
129                .map(|r| r.affected_files)
130                .unwrap_or_default(),
131            GraphProvider::GraphIndex(i) => i.get_related(file_path, depth),
132        }
133    }
134
135    pub fn file_paths(&self) -> Vec<String> {
136        match self {
137            GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
138            GraphProvider::GraphIndex(i) => {
139                let mut paths: Vec<String> = i.files.keys().cloned().collect();
140                paths.sort();
141                paths
142            }
143        }
144    }
145
146    pub fn file_count(&self) -> usize {
147        match self {
148            GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
149            GraphProvider::GraphIndex(i) => i.files.len(),
150        }
151    }
152
153    pub fn symbol_count(&self) -> usize {
154        match self {
155            GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
156            GraphProvider::GraphIndex(i) => i.symbols.len(),
157        }
158    }
159
160    pub fn find_symbols(
161        &self,
162        name: &str,
163        file_filter: Option<&str>,
164        kind_filter: Option<&str>,
165    ) -> Vec<SymbolInfo> {
166        match self {
167            GraphProvider::PropertyGraph(g) => g
168                .find_symbols(name, file_filter, kind_filter)
169                .unwrap_or_default()
170                .into_iter()
171                .map(symbol_info_from_node)
172                .collect(),
173            GraphProvider::GraphIndex(i) => {
174                let name_lower = name.to_lowercase();
175                i.symbols
176                    .values()
177                    .filter(|s| s.name.to_lowercase().contains(&name_lower))
178                    .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
179                    .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
180                    .take(100)
181                    .map(|s| SymbolInfo {
182                        name: s.name.clone(),
183                        file: s.file.clone(),
184                        kind: s.kind.clone(),
185                        start_line: s.start_line,
186                        end_line: s.end_line,
187                        is_exported: s.is_exported,
188                    })
189                    .collect()
190            }
191        }
192    }
193
194    /// Every symbol with its file + line span (unfiltered). Backend-agnostic
195    /// equivalent of iterating `ProjectIndex::symbols` — used by the call-graph
196    /// builder to attribute call sites to their enclosing symbol.
197    pub fn all_symbols(&self) -> Vec<SymbolInfo> {
198        match self {
199            GraphProvider::PropertyGraph(g) => g
200                .all_symbols()
201                .unwrap_or_default()
202                .into_iter()
203                .map(symbol_info_from_node)
204                .collect(),
205            GraphProvider::GraphIndex(i) => i
206                .symbols
207                .values()
208                .map(|s| SymbolInfo {
209                    name: s.name.clone(),
210                    file: s.file.clone(),
211                    kind: s.kind.clone(),
212                    start_line: s.start_line,
213                    end_line: s.end_line,
214                    is_exported: s.is_exported,
215                })
216                .collect(),
217        }
218    }
219
220    pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
221        match self {
222            GraphProvider::PropertyGraph(g) => {
223                // Keys are `rel_path::sym_name` (graph_index, see `mod.rs`). A
224                // file path never contains `::`, but a symbol name does for trait
225                // impls (`std::fmt::Display for T`). Split on the FIRST `::` so
226                // those names round-trip — `rsplitn` mangled them (#682.3).
227                let parts: Vec<&str> = key.splitn(2, "::").collect();
228                if parts.len() != 2 {
229                    return None;
230                }
231                let (file_path, sym_name) = (parts[0], parts[1]);
232                g.get_node_by_symbol(sym_name, file_path)
233                    .ok()
234                    .flatten()
235                    .map(symbol_info_from_node)
236            }
237            GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
238                name: s.name.clone(),
239                file: s.file.clone(),
240                kind: s.kind.clone(),
241                start_line: s.start_line,
242                end_line: s.end_line,
243                is_exported: s.is_exported,
244            }),
245        }
246    }
247
248    pub fn edges(&self) -> Vec<EdgeInfo> {
249        match self {
250            // Normalize the raw property-graph edge-kind vocabulary back to the
251            // graph_index vocabulary every consumer speaks (notably `imports` →
252            // `import`, queried by impact/overview via `edges_by_kind("import")`).
253            // Without this the facade leaks `EdgeKind::as_str()` plurals and PG
254            // vs legacy backends would answer the same query differently (#696).
255            GraphProvider::PropertyGraph(g) => g
256                .all_edges_flat()
257                .unwrap_or_default()
258                .into_iter()
259                .map(|(from, to, kind, weight)| EdgeInfo {
260                    from,
261                    to,
262                    kind: index_edge_kind(&kind),
263                    weight,
264                })
265                .collect(),
266            GraphProvider::GraphIndex(i) => i
267                .edges
268                .iter()
269                .map(|e| EdgeInfo {
270                    from: e.from.clone(),
271                    to: e.to.clone(),
272                    kind: e.kind.clone(),
273                    weight: e.weight as f64,
274                })
275                .collect(),
276        }
277    }
278
279    pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
280        self.edges()
281            .into_iter()
282            .filter(|e| e.kind == kind)
283            .collect()
284    }
285
286    /// Every catalogued file as [`FileInfo`]. Backend-agnostic equivalent of
287    /// iterating `ProjectIndex::files` — used by stats/bootstrap consumers that
288    /// need per-file language + token counts, not just paths.
289    pub fn file_entries(&self) -> Vec<FileInfo> {
290        match self {
291            GraphProvider::PropertyGraph(_) => self
292                .file_paths()
293                .into_iter()
294                .filter_map(|p| self.get_file_entry(&p))
295                .collect(),
296            GraphProvider::GraphIndex(i) => i
297                .files
298                .values()
299                .map(|e| FileInfo {
300                    path: e.path.clone(),
301                    hash: e.hash.clone(),
302                    language: e.language.clone(),
303                    line_count: e.line_count,
304                    token_count: e.token_count,
305                    exports: e.exports.clone(),
306                    summary: e.summary.clone(),
307                })
308                .collect(),
309        }
310    }
311
312    pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
313        match self {
314            GraphProvider::PropertyGraph(g) => {
315                g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
316                    path: e.path,
317                    hash: e.hash,
318                    language: e.language,
319                    line_count: e.line_count,
320                    token_count: e.token_count,
321                    exports: e.exports,
322                    summary: e.summary,
323                })
324            }
325            GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
326                path: e.path.clone(),
327                hash: e.hash.clone(),
328                language: e.language.clone(),
329                line_count: e.line_count,
330                token_count: e.token_count,
331                exports: e.exports.clone(),
332                summary: e.summary.clone(),
333            }),
334        }
335    }
336
337    pub fn last_scan(&self) -> String {
338        match self {
339            GraphProvider::PropertyGraph(_) => String::new(),
340            GraphProvider::GraphIndex(i) => i.last_scan.clone(),
341        }
342    }
343
344    /// Reconstruct a full [`ProjectIndex`] from this provider — the inverse of
345    /// the graph_index→PG mirror
346    /// ([`populate_from_project_index`](super::property_graph::populate_from_project_index)).
347    /// Lets the
348    /// remaining legacy `ProjectIndex` consumers be sourced from the
349    /// PropertyGraph (parity-proven lossless, #682.3) so the redundant JSON
350    /// store can be retired (#696 phase C). For the GraphIndex backend it clones
351    /// the index it already holds.
352    pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
353        if let GraphProvider::GraphIndex(i) = self {
354            return i.clone();
355        }
356        let mut idx = ProjectIndex::new(project_root);
357        // Stamp `last_scan` from the graph's build time (graph.meta.json) so the
358        // TTL staleness check reflects the real build age, not this
359        // materialization moment (#696 C4). Content-based staleness still keys
360        // off the meta file's mtime independently.
361        if let Some(meta) = super::property_graph::load_meta(project_root)
362            && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
363        {
364            idx.last_scan = dt
365                .with_timezone(&chrono::Local)
366                .format("%Y-%m-%d %H:%M:%S")
367                .to_string();
368        }
369        for f in self.file_entries() {
370            idx.files.insert(
371                f.path.clone(),
372                graph_index::FileEntry {
373                    path: f.path,
374                    hash: f.hash,
375                    language: f.language,
376                    line_count: f.line_count,
377                    token_count: f.token_count,
378                    exports: f.exports,
379                    summary: f.summary,
380                },
381            );
382        }
383        for s in self.all_symbols() {
384            let key = format!("{}::{}", s.file, s.name);
385            idx.symbols.insert(
386                key,
387                graph_index::SymbolEntry {
388                    file: s.file,
389                    name: s.name,
390                    kind: s.kind,
391                    start_line: s.start_line,
392                    end_line: s.end_line,
393                    is_exported: s.is_exported,
394                },
395            );
396        }
397        for e in self.edges() {
398            // `edges()` already normalizes PG kinds to the graph_index
399            // vocabulary, so `e.kind` is ready to store verbatim.
400            idx.edges.push(graph_index::IndexEdge {
401                from: e.from,
402                to: e.to,
403                kind: e.kind,
404                weight: e.weight as f32,
405            });
406        }
407        idx
408    }
409
410    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
411        graph_index::ProjectIndex::index_dir(project_root)
412    }
413
414    /// Scored related files using multi-edge weights.
415    /// Falls back to unscored deps/dependents for GraphIndex backend.
416    pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
417        match self {
418            GraphProvider::PropertyGraph(g) => {
419                g.related_files(file_path, limit).unwrap_or_default()
420            }
421            GraphProvider::GraphIndex(_) => {
422                let mut result: Vec<(String, f64)> = Vec::new();
423                for dep in self.dependencies(file_path) {
424                    result.push((dep, 1.0));
425                }
426                for dep in self.dependents(file_path) {
427                    if !result.iter().any(|(p, _)| *p == dep) {
428                        result.push((dep, 0.5));
429                    }
430                }
431                result.truncate(limit);
432                result
433            }
434        }
435    }
436}
437
438/// Open whichever graph is already populated on disk, **without** triggering any
439/// build, plus a flag telling the caller the property graph still wants a
440/// (re)build. Prefers a fully-populated PropertyGraph and falls back to the
441/// in-memory graph_index extractor while the PG is not yet populated (first run
442/// or just after a rebuild), flagging `needs_build` so the caller can warm the
443/// PG for the next call (#696 phase D: the `legacy` backend escape hatch was
444/// retired once PG-only persistence proved lossless in #682.3).
445fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
446    let t0 = std::time::Instant::now();
447
448    let mut pg_provider = None;
449    let mut pg_populated = false;
450    if let Ok(pg) = CodeGraph::open(project_root) {
451        let nodes = pg.node_count().unwrap_or(0);
452        let edges = pg.edge_count().unwrap_or(0);
453        let file_cat = pg.file_catalog_count().unwrap_or(0);
454        pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
455        if pg_populated {
456            log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
457            return (
458                Some(OpenGraphProvider {
459                    source: GraphProviderSource::PropertyGraph,
460                    provider: GraphProvider::PropertyGraph(pg),
461                }),
462                false,
463            );
464        }
465        if nodes > 0 && file_cat > 0 {
466            pg_provider = Some(pg);
467        }
468    }
469
470    // PG is not fully populated: a (re)build would help the next call.
471    let needs_build = !pg_populated;
472
473    if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
474        let files = idx.files.len();
475        let edges = idx.edges.len();
476        if !idx.edges.is_empty() || !idx.files.is_empty() {
477            log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
478            return (
479                Some(OpenGraphProvider {
480                    source: GraphProviderSource::GraphIndex,
481                    provider: GraphProvider::GraphIndex(idx),
482                }),
483                needs_build,
484            );
485        }
486    }
487
488    if let Some(pg) = pg_provider {
489        let nodes = pg.node_count().unwrap_or(0);
490        log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
491        return (
492            Some(OpenGraphProvider {
493                source: GraphProviderSource::PropertyGraph,
494                provider: GraphProvider::PropertyGraph(pg),
495            }),
496            needs_build,
497        );
498    }
499
500    (None, needs_build)
501}
502
503/// Open an already-built graph, kicking off a one-shot background build when the
504/// property graph is not fully populated so the *next* call is fast. Returns
505/// `None` on this call when nothing is ready yet. Best-effort callers
506/// (dashboards, context gate, stats, `ctx_graph`) use this; callers that need a
507/// graph *right now* use [`open_or_build`], which builds synchronously instead.
508pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
509    let (existing, needs_build) = open_existing(project_root);
510    if needs_build {
511        trigger_lazy_graph_build(project_root);
512    }
513    existing
514}
515
516fn log_source_selection(
517    source: GraphProviderSource,
518    nodes: usize,
519    edges: usize,
520    start: std::time::Instant,
521) {
522    let elapsed_ms = start.elapsed().as_millis();
523    if std::env::var("LCTX_DEBUG").is_ok() {
524        eprintln!(
525            "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
526        );
527    }
528    let _ = (source, nodes, edges, elapsed_ms);
529}
530
531/// Triggers a background graph build once per process when the graph is empty.
532fn trigger_lazy_graph_build(project_root: &str) {
533    // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses
534    // its own tempdir). A detached, fire-and-forget build thread reads that global
535    // mid-flight and runs concurrently with the otherwise-serial (`--test-threads=1`)
536    // test bodies — the one source of graph-state concurrency in the suite, and the
537    // root of an intermittent macOS-only flake where a freshly-built index appeared
538    // empty to the asserting test. `open_or_build` has a synchronous fallback that
539    // fully covers tests, so skip the background build under `cfg!(test)`. Production
540    // (and integration tests, which run the lib normally) are unaffected.
541    if cfg!(test) {
542        return;
543    }
544    if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
545        return;
546    }
547    let root = Path::new(project_root);
548    // Both probes are TCC-guarded (#356): a non-existent/non-dir path has no
549    // markers, and a launchd-standalone process never stats under ~/Documents.
550    let is_project = crate::core::pathutil::has_project_marker(root)
551        || crate::core::pathutil::has_multi_repo_children(root);
552    if !is_project {
553        return;
554    }
555    // #682.2: build via the same reliable worker that builds the JSON index
556    // (which now mirrors into PG, backend-gated), instead of a dedicated
557    // fire-and-forget thread that silently died in short-lived processes.
558    super::index_orchestrator::ensure_all_background(project_root);
559}
560
561/// Build the property graph from the proven graph_index extractor (#682.1).
562///
563/// Loads the current [`ProjectIndex`] (or scans if absent) and mirrors it into
564/// the SQLite store — files + `file_catalog`, symbols, and structural edges —
565/// then stamps `graph.meta.json`. Sourcing PG from the mature extractor
566/// guarantees PG ⊇ graph_index (so a later backend flip cannot lose data) and
567/// populates the `file_catalog` that the `pg_populated` gate requires.
568///
569/// Synchronous and self-contained in `core` (no `tools` dependency), so callers
570/// can build reliably without the fire-and-forget caveat of the lazy trigger.
571pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
572    let index = super::index_orchestrator::try_load_graph_index(project_root)
573        .filter(|i| !i.files.is_empty())
574        .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
575    super::property_graph::mirror_index(project_root, &index)
576}
577
578pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
579    // Open via `open_existing` (not `open_best_effort`): we build synchronously
580    // below, so we must NOT spawn the background indexer. Its worker holds the
581    // graph-index file lock, which would starve the synchronous scan into
582    // returning an empty index — the "No graph available" first-call regression
583    // after the PropertyGraph default flip (#695/#682.2).
584    if let (Some(p), _) = open_existing(project_root) {
585        return Some(p);
586    }
587    let idx = super::graph_index::load_or_build(project_root);
588    if idx.files.is_empty() {
589        return None;
590    }
591    Some(OpenGraphProvider {
592        source: GraphProviderSource::GraphIndex,
593        provider: GraphProvider::GraphIndex(idx),
594    })
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn best_effort_prefers_graph_index_when_property_graph_empty() {
603        let _lock = crate::core::data_dir::test_env_lock();
604        let tmp = tempfile::tempdir().expect("tempdir");
605        let data = tmp.path().join("data");
606        std::fs::create_dir_all(&data).expect("mkdir data");
607        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
608
609        let project_root = tmp.path().join("proj");
610        std::fs::create_dir_all(&project_root).expect("mkdir proj");
611        let root = project_root.to_string_lossy().to_string();
612
613        let mut idx = ProjectIndex::new(&root);
614        idx.files.insert(
615            "src/main.rs".to_string(),
616            super::super::graph_index::FileEntry {
617                path: "src/main.rs".to_string(),
618                hash: "h".to_string(),
619                language: "rs".to_string(),
620                line_count: 1,
621                token_count: 1,
622                exports: vec![],
623                summary: String::new(),
624            },
625        );
626        idx.save().expect("save index");
627
628        let open = open_best_effort(&root).expect("open");
629        assert_eq!(open.source, GraphProviderSource::GraphIndex);
630
631        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
632    }
633
634    #[test]
635    fn best_effort_none_when_no_graphs() {
636        let _lock = crate::core::data_dir::test_env_lock();
637        let tmp = tempfile::tempdir().expect("tempdir");
638        let data = tmp.path().join("data");
639        std::fs::create_dir_all(&data).expect("mkdir data");
640        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
641
642        let project_root = tmp.path().join("proj");
643        std::fs::create_dir_all(&project_root).expect("mkdir proj");
644        let root = project_root.to_string_lossy().to_string();
645
646        let open = open_best_effort(&root);
647        assert!(open.is_none());
648
649        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
650    }
651
652    #[test]
653    fn parity_dependencies_both_stores_agree() {
654        use super::super::graph_index::{FileEntry, IndexEdge};
655        use super::super::property_graph::{Edge, EdgeKind, Node};
656
657        let pg = CodeGraph::open_in_memory().unwrap();
658        let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
659        let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
660        let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
661        pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
662            .unwrap();
663        pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
664            .unwrap();
665
666        let mut idx = ProjectIndex::new("/test");
667        for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
668            idx.files.insert(
669                name.to_string(),
670                FileEntry {
671                    path: name.to_string(),
672                    hash: "h".into(),
673                    language: "rs".into(),
674                    line_count: 1,
675                    token_count: 1,
676                    exports: vec![],
677                    summary: String::new(),
678                },
679            );
680        }
681        idx.edges.push(IndexEdge {
682            from: "src/a.rs".into(),
683            to: "src/b.rs".into(),
684            kind: "import".into(),
685            weight: 1.0,
686        });
687        idx.edges.push(IndexEdge {
688            from: "src/a.rs".into(),
689            to: "src/c.rs".into(),
690            kind: "import".into(),
691            weight: 1.0,
692        });
693
694        let pg_deps = GraphProvider::PropertyGraph(pg);
695        let gi_deps = GraphProvider::GraphIndex(idx);
696
697        let mut pg_result = pg_deps.dependencies("src/a.rs");
698        let mut gi_result = gi_deps.dependencies("src/a.rs");
699        pg_result.sort();
700        gi_result.sort();
701
702        assert_eq!(
703            pg_result, gi_result,
704            "Import edges must match between PG and GraphIndex"
705        );
706
707        let mut pg_dependents = pg_deps.dependents("src/b.rs");
708        let mut gi_dependents = gi_deps.dependents("src/b.rs");
709        pg_dependents.sort();
710        gi_dependents.sort();
711        assert_eq!(
712            pg_dependents, gi_dependents,
713            "Dependents must match between PG and GraphIndex"
714        );
715    }
716
717    /// Round-trip guard for #696 C1: graph_index → PG (mirror) → graph_index
718    /// (materialize) must preserve files (full catalog), symbols (incl. the
719    /// precise `kind` + `is_exported`, which live in node metadata) and import
720    /// edges. Proves the PropertyGraph can be the sole source for the remaining
721    /// legacy `ProjectIndex` consumers — the prerequisite for retiring the JSON
722    /// store.
723    #[test]
724    fn materialize_project_index_round_trips_losslessly() {
725        use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
726        use super::super::property_graph::populate_from_project_index;
727
728        let mut a = ProjectIndex::new("/test");
729        a.files.insert(
730            "src/a.rs".to_string(),
731            FileEntry {
732                path: "src/a.rs".to_string(),
733                hash: "hash-a".to_string(),
734                language: "rs".to_string(),
735                line_count: 42,
736                token_count: 137,
737                exports: vec!["Foo".to_string()],
738                summary: "module a".to_string(),
739            },
740        );
741        a.files.insert(
742            "src/b.rs".to_string(),
743            FileEntry {
744                path: "src/b.rs".to_string(),
745                hash: "hash-b".to_string(),
746                language: "rs".to_string(),
747                line_count: 7,
748                token_count: 19,
749                exports: vec![],
750                summary: String::new(),
751            },
752        );
753        // Two symbols with DIFFERENT kinds and export flags — the fields most at
754        // risk of being flattened by the coarse property-graph `NodeKind`.
755        a.symbols.insert(
756            "src/a.rs::Foo".to_string(),
757            SymbolEntry {
758                file: "src/a.rs".to_string(),
759                name: "Foo".to_string(),
760                kind: "struct".to_string(),
761                start_line: 1,
762                end_line: 9,
763                is_exported: true,
764            },
765        );
766        a.symbols.insert(
767            "src/b.rs::helper".to_string(),
768            SymbolEntry {
769                file: "src/b.rs".to_string(),
770                name: "helper".to_string(),
771                kind: "function".to_string(),
772                start_line: 3,
773                end_line: 6,
774                is_exported: false,
775            },
776        );
777        a.edges.push(IndexEdge {
778            from: "src/b.rs".to_string(),
779            to: "src/a.rs".to_string(),
780            kind: "import".to_string(),
781            weight: 1.0,
782        });
783
784        let pg = CodeGraph::open_in_memory().unwrap();
785        populate_from_project_index(&pg, &a).unwrap();
786        let provider = GraphProvider::PropertyGraph(pg);
787        let b = provider.materialize_project_index("/test");
788
789        // Files: inventory + every catalog field survive.
790        let mut a_files: Vec<&String> = a.files.keys().collect();
791        let mut b_files: Vec<&String> = b.files.keys().collect();
792        a_files.sort();
793        b_files.sort();
794        assert_eq!(a_files, b_files, "file inventory must round-trip");
795        for (path, fa) in &a.files {
796            let fb = b.files.get(path).expect("file present after round trip");
797            assert_eq!(fa.hash, fb.hash, "hash {path}");
798            assert_eq!(fa.language, fb.language, "language {path}");
799            assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
800            assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
801            assert_eq!(fa.exports, fb.exports, "exports {path}");
802            assert_eq!(fa.summary, fb.summary, "summary {path}");
803        }
804
805        // Symbols: keys + spans + the metadata-carried kind/export flag survive.
806        let mut a_syms: Vec<&String> = a.symbols.keys().collect();
807        let mut b_syms: Vec<&String> = b.symbols.keys().collect();
808        a_syms.sort();
809        b_syms.sort();
810        assert_eq!(a_syms, b_syms, "symbol table must round-trip");
811        for (key, sa) in &a.symbols {
812            let sb = b.symbols.get(key).expect("symbol present after round trip");
813            assert_eq!(sa.name, sb.name, "name {key}");
814            assert_eq!(sa.file, sb.file, "file {key}");
815            assert_eq!(sa.kind, sb.kind, "kind {key}");
816            assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
817            assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
818            assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
819        }
820
821        // Structural edges: the import edge survives (PG may enrich, never lose).
822        assert!(
823            b.edges
824                .iter()
825                .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
826            "import edge must round-trip; got {:?}",
827            b.edges
828        );
829    }
830}