Skip to main content

lean_ctx/core/property_graph/
snapshot.rs

1//! Versioned, committable graph snapshots — Context-as-Code (GL#451).
2//!
3//! Exports the property graph as deterministic JSON Lines: one header, one
4//! line per node, one line per edge, all stably sorted and free of local
5//! AUTOINCREMENT ids (edges reference nodes by their (kind, name, file)
6//! identity). The same graph always serializes to the same bytes, so the
7//! snapshot can live in git, diff cleanly, and merge across team members.
8
9use std::collections::HashMap;
10
11use rusqlite::params;
12use serde::{Deserialize, Serialize};
13
14use super::{CodeGraph, Edge, EdgeKind, Node, NodeKind};
15
16pub const SNAPSHOT_VERSION: u32 = 1;
17
18#[derive(Debug, Serialize, Deserialize)]
19struct SnapshotHeader {
20    leanctx_graph_snapshot: u32,
21    nodes: usize,
22    edges: usize,
23}
24
25/// Node identity + payload, id-free. Field order = serialization order.
26#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
27struct SnapNode {
28    kind: String,
29    file: String,
30    name: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    line_start: Option<usize>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    line_end: Option<usize>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    meta: Option<String>,
37}
38
39/// Edge with endpoints referenced by node identity, id-free.
40#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
41struct SnapEdge {
42    kind: String,
43    source: (String, String, String),
44    target: (String, String, String),
45    #[serde(skip_serializing_if = "Option::is_none")]
46    meta: Option<String>,
47}
48
49#[derive(Debug, Default)]
50pub struct ImportStats {
51    pub nodes: usize,
52    pub edges: usize,
53    pub skipped_edges: usize,
54}
55
56#[derive(Debug, Default)]
57pub struct DriftReport {
58    pub only_local: usize,
59    pub only_snapshot: usize,
60    pub common: usize,
61}
62
63impl DriftReport {
64    pub fn in_sync(&self) -> bool {
65        self.only_local == 0 && self.only_snapshot == 0
66    }
67}
68
69fn collect_nodes(graph: &CodeGraph) -> anyhow::Result<Vec<SnapNode>> {
70    let conn = graph.connection();
71    let mut stmt =
72        conn.prepare("SELECT n.kind, p.path, n.name, n.line_start, n.line_end, n.metadata FROM nodes n JOIN paths p ON p.id = n.file_id")?;
73    let mut nodes: Vec<SnapNode> = stmt
74        .query_map([], |row| {
75            Ok(SnapNode {
76                kind: row.get(0)?,
77                file: row.get(1)?,
78                name: row.get(2)?,
79                line_start: row.get::<_, Option<i64>>(3)?.map(|v| v as usize),
80                line_end: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
81                meta: row.get(5)?,
82            })
83        })?
84        .collect::<Result<_, _>>()?;
85    nodes.sort();
86    Ok(nodes)
87}
88
89fn collect_edges(graph: &CodeGraph) -> anyhow::Result<Vec<SnapEdge>> {
90    let conn = graph.connection();
91    let mut stmt = conn.prepare(
92        "SELECT e.kind, e.metadata,
93                s.kind, sp.path, s.name,
94                t.kind, tp.path, t.name
95         FROM edges e
96         JOIN nodes s ON s.id = e.source_id
97         JOIN nodes t ON t.id = e.target_id
98         JOIN paths sp ON sp.id = s.file_id
99         JOIN paths tp ON tp.id = t.file_id",
100    )?;
101    let mut edges: Vec<SnapEdge> = stmt
102        .query_map([], |row| {
103            Ok(SnapEdge {
104                kind: row.get(0)?,
105                meta: row.get(1)?,
106                source: (row.get(2)?, row.get(3)?, row.get(4)?),
107                target: (row.get(5)?, row.get(6)?, row.get(7)?),
108            })
109        })?
110        .collect::<Result<_, _>>()?;
111    edges.sort();
112    Ok(edges)
113}
114
115/// Serialize the whole graph as deterministic JSON Lines.
116pub fn export_snapshot(graph: &CodeGraph) -> anyhow::Result<String> {
117    let nodes = collect_nodes(graph)?;
118    let edges = collect_edges(graph)?;
119
120    let mut out = String::new();
121    out.push_str(&serde_json::to_string(&SnapshotHeader {
122        leanctx_graph_snapshot: SNAPSHOT_VERSION,
123        nodes: nodes.len(),
124        edges: edges.len(),
125    })?);
126    out.push('\n');
127    for n in &nodes {
128        out.push_str(&format!("{{\"n\":{}}}\n", serde_json::to_string(n)?));
129    }
130    for e in &edges {
131        out.push_str(&format!("{{\"e\":{}}}\n", serde_json::to_string(e)?));
132    }
133    Ok(out)
134}
135
136/// Merge a snapshot into the local graph: nodes and edges are upserted, the
137/// local graph is never truncated (local-first merge — newer local scan data
138/// wins on conflicting node payloads via the upsert).
139pub fn import_snapshot(graph: &CodeGraph, content: &str) -> anyhow::Result<ImportStats> {
140    let (nodes, edges) = parse_snapshot(content)?;
141    let mut stats = ImportStats::default();
142    let mut id_by_identity: HashMap<(String, String, String), i64> = HashMap::new();
143
144    for n in &nodes {
145        let node = Node {
146            id: None,
147            kind: NodeKind::parse(&n.kind),
148            name: n.name.clone(),
149            file_path: n.file.clone(),
150            line_start: n.line_start,
151            line_end: n.line_end,
152            metadata: n.meta.clone(),
153        };
154        let id = graph.upsert_node(&node)?;
155        id_by_identity.insert((n.kind.clone(), n.file.clone(), n.name.clone()), id);
156        stats.nodes += 1;
157    }
158
159    for e in &edges {
160        let source = resolve_endpoint(graph, &mut id_by_identity, &e.source);
161        let target = resolve_endpoint(graph, &mut id_by_identity, &e.target);
162        match (source, target) {
163            (Some(s), Some(t)) => {
164                graph.upsert_edge(&Edge {
165                    id: None,
166                    source_id: s,
167                    target_id: t,
168                    kind: EdgeKind::parse(&e.kind),
169                    metadata: e.meta.clone(),
170                })?;
171                stats.edges += 1;
172            }
173            _ => stats.skipped_edges += 1,
174        }
175    }
176
177    Ok(stats)
178}
179
180/// Compare the local graph against a snapshot, line-set based.
181pub fn check_snapshot(graph: &CodeGraph, content: &str) -> anyhow::Result<DriftReport> {
182    let local = export_snapshot(graph)?;
183    let local_set: std::collections::HashSet<&str> =
184        local.lines().skip(1).filter(|l| !l.is_empty()).collect();
185    let snap_set: std::collections::HashSet<&str> =
186        content.lines().skip(1).filter(|l| !l.is_empty()).collect();
187
188    Ok(DriftReport {
189        only_local: local_set.difference(&snap_set).count(),
190        only_snapshot: snap_set.difference(&local_set).count(),
191        common: local_set.intersection(&snap_set).count(),
192    })
193}
194
195fn resolve_endpoint(
196    graph: &CodeGraph,
197    cache: &mut HashMap<(String, String, String), i64>,
198    identity: &(String, String, String),
199) -> Option<i64> {
200    if let Some(&id) = cache.get(identity) {
201        return Some(id);
202    }
203    // Endpoint may already exist locally without being part of the snapshot.
204    let conn = graph.connection();
205    let found: Option<i64> = conn
206        .query_row(
207            "SELECT n.id FROM nodes n JOIN paths p ON p.id = n.file_id
208             WHERE n.kind = ?1 AND p.path = ?2 AND n.name = ?3",
209            params![identity.0, identity.1, identity.2],
210            |row| row.get(0),
211        )
212        .ok();
213    if let Some(id) = found {
214        cache.insert(identity.clone(), id);
215    }
216    found
217}
218
219fn parse_snapshot(content: &str) -> anyhow::Result<(Vec<SnapNode>, Vec<SnapEdge>)> {
220    let mut lines = content.lines().filter(|l| !l.trim().is_empty());
221    let header_line = lines
222        .next()
223        .ok_or_else(|| anyhow::anyhow!("empty snapshot"))?;
224    let header: SnapshotHeader = serde_json::from_str(header_line)
225        .map_err(|e| anyhow::anyhow!("invalid snapshot header: {e}"))?;
226    if header.leanctx_graph_snapshot != SNAPSHOT_VERSION {
227        anyhow::bail!(
228            "unsupported snapshot version {} (supported: {SNAPSHOT_VERSION})",
229            header.leanctx_graph_snapshot
230        );
231    }
232
233    let mut nodes = Vec::new();
234    let mut edges = Vec::new();
235    for line in lines {
236        let v: serde_json::Value = serde_json::from_str(line)
237            .map_err(|e| anyhow::anyhow!("invalid snapshot line: {e}"))?;
238        if let Some(n) = v.get("n") {
239            nodes.push(serde_json::from_value::<SnapNode>(n.clone())?);
240        } else if let Some(e) = v.get("e") {
241            edges.push(serde_json::from_value::<SnapEdge>(e.clone())?);
242        } else {
243            anyhow::bail!("unknown snapshot line shape: {line}");
244        }
245    }
246    Ok((nodes, edges))
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn graph_with_data() -> CodeGraph {
254        let g = CodeGraph::open_in_memory().unwrap();
255        let a = g.upsert_node(&Node::file("src/auth.rs")).expect("node a");
256        let b = g.upsert_node(&Node::file("src/db.rs")).expect("node b");
257        g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports))
258            .expect("edge");
259        g
260    }
261
262    #[test]
263    fn export_is_deterministic_and_id_free() {
264        let g = graph_with_data();
265        let s1 = export_snapshot(&g).unwrap();
266        let s2 = export_snapshot(&g).unwrap();
267        assert_eq!(s1, s2);
268        assert!(!s1.contains("\"id\""), "snapshot must not leak local ids");
269        assert!(s1.starts_with("{\"leanctx_graph_snapshot\":1"));
270    }
271
272    #[test]
273    fn roundtrip_export_import_is_lossless() {
274        let g = graph_with_data();
275        let snapshot = export_snapshot(&g).unwrap();
276
277        let fresh = CodeGraph::open_in_memory().unwrap();
278        let stats = import_snapshot(&fresh, &snapshot).unwrap();
279        assert_eq!(stats.nodes, 2);
280        assert_eq!(stats.edges, 1);
281        assert_eq!(stats.skipped_edges, 0);
282
283        let reexported = export_snapshot(&fresh).unwrap();
284        assert_eq!(snapshot, reexported, "roundtrip must be lossless");
285    }
286
287    #[test]
288    fn import_merges_instead_of_replacing() {
289        let g = graph_with_data();
290        let snapshot = export_snapshot(&g).unwrap();
291
292        let local = CodeGraph::open_in_memory().unwrap();
293        local
294            .upsert_node(&Node::file("src/local_only.rs"))
295            .expect("local node");
296
297        import_snapshot(&local, &snapshot).unwrap();
298        let merged = export_snapshot(&local).unwrap();
299        assert!(merged.contains("local_only.rs"), "local data must survive");
300        assert!(merged.contains("auth.rs"), "snapshot data must be merged");
301    }
302
303    #[test]
304    fn check_reports_drift_and_sync() {
305        let g = graph_with_data();
306        let snapshot = export_snapshot(&g).unwrap();
307
308        let synced = check_snapshot(&g, &snapshot).unwrap();
309        assert!(synced.in_sync());
310
311        g.upsert_node(&Node::file("src/new_file.rs")).unwrap();
312        let drifted = check_snapshot(&g, &snapshot).unwrap();
313        assert!(!drifted.in_sync());
314        assert_eq!(drifted.only_local, 1);
315        assert_eq!(drifted.only_snapshot, 0);
316    }
317
318    #[test]
319    fn rejects_wrong_version() {
320        let g = CodeGraph::open_in_memory().unwrap();
321        let err = import_snapshot(
322            &g,
323            "{\"leanctx_graph_snapshot\":99,\"nodes\":0,\"edges\":0}\n",
324        )
325        .unwrap_err();
326        assert!(err.to_string().contains("unsupported snapshot version"));
327    }
328}