lean_ctx/core/property_graph/
snapshot.rs1use 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#[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#[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
115pub 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
136pub 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
180pub 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 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}