Skip to main content

lean_ctx/core/gateway/adapters/
code_graph.rs

1//! code-graph adapter (#1098): Graphify-style node/edge output → property-graph
2//! cross-source edges, so `ctx_callgraph` and `ctx_read` hints benefit from an
3//! externally-computed code graph.
4//!
5//! Defensive JSON parsing: accepts the common shapes
6//!   - `{ "edges": [ {from|source, to|target, type|kind|relation}, … ] }`
7//!   - a top-level array of edge objects
8//!
9//! and skips anything it does not recognize (best-effort, never panics). Runs as
10//! a side-channel (background thread), so it cannot affect output determinism.
11
12use crate::core::property_graph::CodeGraph;
13
14struct ParsedEdge {
15    from: String,
16    to: String,
17    kind: String,
18}
19
20/// Parse `text` for graph edges and merge them into the project's property graph
21/// as cross-source edges. No-op on unparseable input or an empty edge set.
22pub fn ingest(server: &str, _tool: &str, text: &str, project_root: &str) {
23    let edges = parse_edges(text);
24    if edges.is_empty() {
25        return;
26    }
27    let Ok(pg) = CodeGraph::open(project_root) else {
28        tracing::warn!("[code-graph adapter] property graph open failed for `{server}`");
29        return;
30    };
31    for e in &edges {
32        let kind = if e.kind.is_empty() {
33            format!("{server}_edge")
34        } else {
35            e.kind.clone()
36        };
37        let _ = pg.upsert_cross_source_edge(&e.from, &e.to, &kind, 1.0);
38    }
39}
40
41fn parse_edges(text: &str) -> Vec<ParsedEdge> {
42    let Ok(v) = serde_json::from_str::<serde_json::Value>(text.trim()) else {
43        return Vec::new();
44    };
45    let array = v
46        .get("edges")
47        .and_then(serde_json::Value::as_array)
48        .or_else(|| v.as_array());
49    let Some(items) = array else {
50        return Vec::new();
51    };
52    items.iter().filter_map(parse_one).collect()
53}
54
55fn parse_one(item: &serde_json::Value) -> Option<ParsedEdge> {
56    let obj = item.as_object()?;
57    let from = str_field(obj, &["from", "source", "src", "caller"])?;
58    let to = str_field(obj, &["to", "target", "dst", "callee"])?;
59    let kind = str_field(obj, &["type", "kind", "relation", "label"]).unwrap_or_default();
60    Some(ParsedEdge { from, to, kind })
61}
62
63/// First present, non-empty string field among `keys`.
64fn str_field(obj: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> Option<String> {
65    keys.iter()
66        .filter_map(|k| obj.get(*k).and_then(serde_json::Value::as_str))
67        .map(str::trim)
68        .find(|s| !s.is_empty())
69        .map(String::from)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn parses_edges_object_and_array_shapes() {
78        let obj = r#"{"edges":[{"from":"a.rs","to":"b.rs","type":"calls"}]}"#;
79        assert_eq!(parse_edges(obj).len(), 1);
80        let arr = r#"[{"source":"a.rs","target":"b.rs","relation":"imports"}]"#;
81        let e = parse_edges(arr);
82        assert_eq!(e.len(), 1);
83        assert_eq!(e[0].kind, "imports");
84        assert!(parse_edges("garbage").is_empty());
85        assert!(parse_edges(r#"{"nodes":[]}"#).is_empty());
86    }
87
88    #[test]
89    fn ingest_writes_cross_source_edges() {
90        let _lock = crate::core::data_dir::test_env_lock();
91        let proj = tempfile::tempdir().unwrap();
92        let root = proj.path().to_str().unwrap();
93
94        let payload = r#"{"edges":[
95            {"from":"src/auth.rs","to":"src/db.rs","type":"calls"},
96            {"source":"src/api.rs","target":"src/auth.rs","kind":"imports"}
97        ]}"#;
98        ingest("graphify", "query_graph", payload, root);
99
100        let pg = CodeGraph::open(root).expect("open graph");
101        assert_eq!(pg.cross_source_edge_count().unwrap(), 2);
102        let edges = pg.all_cross_source_edges();
103        assert!(
104            edges
105                .iter()
106                .any(|e| e.from == "src/auth.rs" && e.to == "src/db.rs")
107        );
108    }
109}