1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::causal::CausalGraph;
12use crate::hnsw_service::{HnswService, HnswServiceConfig};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PersistenceConfig {
17 pub data_dir: PathBuf,
19 pub auto_save_interval_secs: Option<u64>,
22}
23
24impl Default for PersistenceConfig {
25 fn default() -> Self {
26 Self {
27 data_dir: PathBuf::from(".weftos/state"),
28 auto_save_interval_secs: None,
29 }
30 }
31}
32
33impl PersistenceConfig {
34 pub fn causal_graph_path(&self) -> PathBuf {
36 self.data_dir.join("causal_graph.json")
37 }
38
39 pub fn hnsw_index_path(&self) -> PathBuf {
41 self.data_dir.join("hnsw_index.json")
42 }
43
44 pub fn chain_path(&self) -> PathBuf {
46 self.data_dir.join("exochain.jsonl")
47 }
48}
49
50pub fn save_causal_graph(
52 config: &PersistenceConfig,
53 graph: &CausalGraph,
54) -> Result<(), std::io::Error> {
55 graph.save_to_file(&config.causal_graph_path())
56}
57
58pub fn load_causal_graph(config: &PersistenceConfig) -> Result<CausalGraph, std::io::Error> {
62 let path = config.causal_graph_path();
63 if !path.exists() {
64 return Ok(CausalGraph::new());
65 }
66 CausalGraph::load_from_file(&path)
67}
68
69pub fn save_hnsw(config: &PersistenceConfig, service: &HnswService) -> Result<(), std::io::Error> {
71 service.save_to_file(&config.hnsw_index_path())
72}
73
74pub fn load_hnsw(config: &PersistenceConfig) -> Result<HnswService, std::io::Error> {
78 let path = config.hnsw_index_path();
79 if !path.exists() {
80 return Ok(HnswService::new(HnswServiceConfig::default()));
81 }
82 HnswService::load_from_file(&path)
83}
84
85pub fn save_all(
87 config: &PersistenceConfig,
88 graph: &CausalGraph,
89 hnsw: &HnswService,
90) -> Result<(), std::io::Error> {
91 std::fs::create_dir_all(&config.data_dir)?;
92 save_causal_graph(config, graph)?;
93 save_hnsw(config, hnsw)?;
94 Ok(())
95}
96
97#[cfg(feature = "exochain")]
99pub fn save_all_with_chain(
100 config: &PersistenceConfig,
101 graph: &CausalGraph,
102 hnsw: &HnswService,
103 cm: &crate::chain::ChainManager,
104) -> Result<(), std::io::Error> {
105 cm.append(
106 "persistence",
107 crate::chain::EVENT_KIND_KERNEL_SAVE,
108 Some(serde_json::json!({
109 "data_dir": config.data_dir.display().to_string(),
110 "node_count": graph.node_count(),
111 "hnsw_count": hnsw.len(),
112 })),
113 );
114 save_all(config, graph, hnsw)
115}
116
117pub fn load_all(config: &PersistenceConfig) -> Result<(CausalGraph, HnswService), std::io::Error> {
121 let graph = load_causal_graph(config)?;
122 let hnsw = load_hnsw(config)?;
123 Ok((graph, hnsw))
124}
125
126#[cfg(feature = "exochain")]
128pub fn load_all_with_chain(
129 config: &PersistenceConfig,
130 cm: &crate::chain::ChainManager,
131) -> Result<(CausalGraph, HnswService), std::io::Error> {
132 let result = load_all(config)?;
133 cm.append(
134 "persistence",
135 crate::chain::EVENT_KIND_KERNEL_LOAD,
136 Some(serde_json::json!({
137 "data_dir": config.data_dir.display().to_string(),
138 "node_count": result.0.node_count(),
139 "hnsw_count": result.1.len(),
140 })),
141 );
142 Ok(result)
143}
144
145#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn tmp_config() -> PersistenceConfig {
152 let dir = tempfile::tempdir()
156 .expect("temp dir")
157 .keep()
158 .join("weftos_persist_test");
159 PersistenceConfig {
160 data_dir: dir,
161 auto_save_interval_secs: None,
162 }
163 }
164
165 #[test]
166 fn config_paths() {
167 let cfg = PersistenceConfig {
168 data_dir: PathBuf::from("/tmp/test"),
169 auto_save_interval_secs: None,
170 };
171 assert_eq!(
172 cfg.causal_graph_path(),
173 PathBuf::from("/tmp/test/causal_graph.json")
174 );
175 assert_eq!(
176 cfg.hnsw_index_path(),
177 PathBuf::from("/tmp/test/hnsw_index.json")
178 );
179 assert_eq!(cfg.chain_path(), PathBuf::from("/tmp/test/exochain.jsonl"));
180 }
181
182 #[test]
183 fn load_missing_returns_defaults() {
184 let cfg = tmp_config();
185 let graph = load_causal_graph(&cfg).unwrap();
186 assert_eq!(graph.node_count(), 0);
187 let hnsw = load_hnsw(&cfg).unwrap();
188 assert!(hnsw.is_empty());
189 }
190
191 #[test]
192 fn save_and_load_all_roundtrip() {
193 let cfg = tmp_config();
194
195 let graph = CausalGraph::new();
196 let a = graph.add_node("A".into(), serde_json::json!({"x": 1}));
197 let b = graph.add_node("B".into(), serde_json::json!({}));
198 graph.link(a, b, crate::causal::CausalEdgeType::Causes, 0.9, 100, 1);
199
200 let hnsw = HnswService::new(HnswServiceConfig::default());
201 hnsw.insert(
202 "v1".into(),
203 vec![1.0, 0.0, 0.0],
204 serde_json::json!({"tag": "first"}),
205 );
206
207 save_all(&cfg, &graph, &hnsw).unwrap();
208
209 let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
210 assert_eq!(loaded_graph.node_count(), 2);
211 assert_eq!(loaded_graph.edge_count(), 1);
212 assert_eq!(loaded_hnsw.len(), 1);
213
214 let _ = std::fs::remove_dir_all(&cfg.data_dir);
216 }
217
218 #[test]
221 fn corrupt_causal_graph_file_returns_error() {
222 let cfg = tmp_config();
223 std::fs::create_dir_all(&cfg.data_dir).unwrap();
224
225 std::fs::write(cfg.causal_graph_path(), b"{{{{not json at all!").unwrap();
227
228 let result = load_causal_graph(&cfg);
229 assert!(result.is_err(), "loading corrupt causal graph should fail");
230 let err = result.unwrap_err();
231 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
232
233 let _ = std::fs::remove_dir_all(&cfg.data_dir);
234 }
235
236 #[test]
237 fn corrupt_hnsw_file_returns_error() {
238 let cfg = tmp_config();
239 std::fs::create_dir_all(&cfg.data_dir).unwrap();
240
241 std::fs::write(cfg.hnsw_index_path(), b"\x00\x01\x02binary garbage").unwrap();
243
244 let result = load_hnsw(&cfg);
245 assert!(result.is_err(), "loading corrupt HNSW index should fail");
246 let err = result.unwrap_err();
247 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
248
249 let _ = std::fs::remove_dir_all(&cfg.data_dir);
250 }
251
252 #[test]
253 fn truncated_json_returns_error() {
254 let cfg = tmp_config();
255 std::fs::create_dir_all(&cfg.data_dir).unwrap();
256
257 std::fs::write(cfg.causal_graph_path(), b"{\"next_node_id\":5,\"nodes\":").unwrap();
259
260 let result = load_causal_graph(&cfg);
261 assert!(result.is_err(), "loading truncated JSON should fail");
262
263 let _ = std::fs::remove_dir_all(&cfg.data_dir);
264 }
265
266 #[test]
267 fn empty_file_returns_error() {
268 let cfg = tmp_config();
269 std::fs::create_dir_all(&cfg.data_dir).unwrap();
270
271 std::fs::write(cfg.causal_graph_path(), b"").unwrap();
273
274 let result = load_causal_graph(&cfg);
275 assert!(result.is_err(), "loading empty file should fail");
276
277 let _ = std::fs::remove_dir_all(&cfg.data_dir);
278 }
279
280 #[test]
283 fn concurrent_saves_do_not_corrupt() {
284 let cfg = tmp_config();
285 std::fs::create_dir_all(&cfg.data_dir).unwrap();
286
287 let graph = CausalGraph::new();
288 for i in 0..100 {
289 graph.add_node(format!("node-{i}"), serde_json::json!({"i": i}));
290 }
291 let hnsw = HnswService::new(HnswServiceConfig::default());
292 for i in 0..50 {
293 hnsw.insert(
294 format!("v{i}"),
295 vec![i as f32, 0.0, 0.0],
296 serde_json::json!({}),
297 );
298 }
299
300 let cfg1 = cfg.clone();
302 let cfg2 = cfg.clone();
303 let graph_ref = &graph;
304 let hnsw_ref = &hnsw;
305
306 std::thread::scope(|s| {
307 let h1 = s.spawn(|| save_all(&cfg1, graph_ref, hnsw_ref));
308 let h2 = s.spawn(|| save_all(&cfg2, graph_ref, hnsw_ref));
309
310 h1.join().unwrap().unwrap();
312 h2.join().unwrap().unwrap();
313 });
314
315 let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
317 assert_eq!(loaded_graph.node_count(), 100);
318 assert_eq!(loaded_hnsw.len(), 50);
319
320 let _ = std::fs::remove_dir_all(&cfg.data_dir);
321 }
322
323 #[test]
326 fn save_to_nonexistent_deep_path_creates_dirs() {
327 let cfg = PersistenceConfig {
328 data_dir: std::env::temp_dir()
329 .join(format!(
330 "weftos_deep_{}",
331 std::time::SystemTime::now()
332 .duration_since(std::time::UNIX_EPOCH)
333 .unwrap()
334 .as_nanos()
335 ))
336 .join("a")
337 .join("b")
338 .join("c"),
339 auto_save_interval_secs: None,
340 };
341
342 let graph = CausalGraph::new();
343 let hnsw = HnswService::new(HnswServiceConfig::default());
344
345 save_all(&cfg, &graph, &hnsw).unwrap();
347 assert!(cfg.causal_graph_path().exists());
348
349 let _ = std::fs::remove_dir_all(
350 cfg.data_dir
351 .parent()
352 .unwrap()
353 .parent()
354 .unwrap()
355 .parent()
356 .unwrap(),
357 );
358 }
359
360 #[test]
361 fn save_to_readonly_dir_fails() {
362 let base = std::env::temp_dir().join(format!(
364 "weftos_ro_{}",
365 std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .unwrap()
368 .as_nanos()
369 ));
370 std::fs::create_dir_all(&base).unwrap();
371
372 let mut perms = std::fs::metadata(&base).unwrap().permissions();
374 #[allow(clippy::permissions_set_readonly_false)]
375 perms.set_readonly(true);
376 std::fs::set_permissions(&base, perms.clone()).unwrap();
377
378 let cfg = PersistenceConfig {
379 data_dir: base.join("state"),
380 auto_save_interval_secs: None,
381 };
382
383 let graph = CausalGraph::new();
384 let hnsw = HnswService::new(HnswServiceConfig::default());
385
386 let result = save_all(&cfg, &graph, &hnsw);
387 assert!(result.is_err(), "saving to read-only dir should fail");
388
389 #[allow(clippy::permissions_set_readonly_false)]
392 perms.set_readonly(false);
393 let _ = std::fs::set_permissions(&base, perms);
394 let _ = std::fs::remove_dir_all(&base);
395 }
396
397 #[test]
400 fn save_load_roundtrip_large_graph() {
401 let cfg = tmp_config();
402
403 let graph = CausalGraph::new();
404 let mut node_ids = Vec::with_capacity(1000);
405 for i in 0..1000 {
406 let nid = graph.add_node(
407 format!("node-{i}"),
408 serde_json::json!({"index": i, "data": "x".repeat(50)}),
409 );
410 node_ids.push(nid);
411 }
412
413 for window in node_ids.windows(2) {
415 graph.link(
416 window[0],
417 window[1],
418 crate::causal::CausalEdgeType::Follows,
419 0.8,
420 window[0],
421 0,
422 );
423 }
424
425 let hnsw = HnswService::new(HnswServiceConfig::default());
426 for i in 0..1000 {
427 hnsw.insert(
428 format!("vec-{i}"),
429 vec![i as f32, (i * 2) as f32, (i * 3) as f32],
430 serde_json::json!({"i": i}),
431 );
432 }
433
434 save_all(&cfg, &graph, &hnsw).unwrap();
435
436 let (loaded_graph, loaded_hnsw) = load_all(&cfg).unwrap();
437 assert_eq!(loaded_graph.node_count(), 1000);
438 assert_eq!(loaded_graph.edge_count(), 999);
439 assert_eq!(loaded_hnsw.len(), 1000);
440
441 let _ = std::fs::remove_dir_all(&cfg.data_dir);
442 }
443
444 #[test]
445 fn save_load_roundtrip_preserves_node_data() {
446 let cfg = tmp_config();
447
448 let graph = CausalGraph::new();
449 let nid = graph.add_node(
450 "important-node".into(),
451 serde_json::json!({"key": "value", "nested": {"a": [1,2,3]}}),
452 );
453
454 let hnsw = HnswService::new(HnswServiceConfig::default());
455
456 save_all(&cfg, &graph, &hnsw).unwrap();
457
458 let (loaded_graph, _) = load_all(&cfg).unwrap();
459 assert_eq!(loaded_graph.node_count(), 1);
460 let nodes = loaded_graph.get_node(nid);
462 assert!(
463 nodes.is_some(),
464 "loaded graph should contain the saved node"
465 );
466 assert_eq!(nodes.unwrap().label, "important-node");
467
468 let _ = std::fs::remove_dir_all(&cfg.data_dir);
469 }
470
471 #[test]
472 fn double_save_load_is_idempotent() {
473 let cfg = tmp_config();
474
475 let graph = CausalGraph::new();
476 graph.add_node("A".into(), serde_json::json!({}));
477 let hnsw = HnswService::new(HnswServiceConfig::default());
478
479 save_all(&cfg, &graph, &hnsw).unwrap();
480 save_all(&cfg, &graph, &hnsw).unwrap();
481
482 let (loaded, _) = load_all(&cfg).unwrap();
483 assert_eq!(loaded.node_count(), 1);
484
485 let _ = std::fs::remove_dir_all(&cfg.data_dir);
486 }
487}