1use crate::log_store::LogEntry;
2use crate::registry::PersistedSession;
3use macp_core::session::Session;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use tokio::fs as tfs;
8use tokio::io::AsyncWriteExt;
9
10use super::StorageBackend;
11
12pub struct FileBackend {
13 base_dir: PathBuf,
14}
15
16impl FileBackend {
17 pub fn new(base_dir: PathBuf) -> io::Result<Self> {
18 fs::create_dir_all(base_dir.join("sessions"))?;
19 Ok(Self { base_dir })
20 }
21
22 fn session_dir(&self, session_id: &str) -> PathBuf {
23 self.base_dir.join("sessions").join(session_id)
24 }
25
26 pub(crate) fn session_file(&self, session_id: &str) -> PathBuf {
27 self.session_dir(session_id).join("session.json")
28 }
29
30 pub(crate) fn log_file(&self, session_id: &str) -> PathBuf {
31 self.session_dir(session_id).join("log.jsonl")
32 }
33
34 async fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
35 let tmp_path = path.with_extension("json.tmp");
36 {
41 let mut file = tfs::File::create(&tmp_path).await?;
42 tokio::io::AsyncWriteExt::write_all(&mut file, data).await?;
43 file.sync_data().await?;
44 }
45 tfs::rename(&tmp_path, path).await?;
46 if let Some(parent) = path.parent() {
47 if let Ok(dir) = tfs::File::open(parent).await {
48 let _ = dir.sync_data().await;
49 }
50 }
51 Ok(())
52 }
53}
54
55#[async_trait::async_trait]
56impl StorageBackend for FileBackend {
57 async fn create_session_storage(&self, session_id: &str) -> io::Result<()> {
58 tfs::create_dir_all(self.session_dir(session_id)).await
59 }
60
61 async fn save_session(&self, session: &Session) -> io::Result<()> {
62 let persisted = PersistedSession::from(session);
63 let bytes = serde_json::to_vec_pretty(&persisted)
64 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
65 Self::atomic_write(&self.session_file(&session.session_id), &bytes).await
66 }
67
68 async fn load_session(&self, session_id: &str) -> io::Result<Option<Session>> {
69 let path = self.session_file(session_id);
70 if tfs::metadata(&path).await.is_err() {
71 return Ok(None);
72 }
73 let bytes = tfs::read(&path).await?;
74 let persisted: PersistedSession = serde_json::from_slice(&bytes)
75 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
76 Ok(Some(Session::from(persisted)))
77 }
78
79 async fn load_all_sessions(&self) -> io::Result<Vec<Session>> {
80 let ids = self.list_session_ids().await?;
81 let mut sessions = Vec::new();
82 for id in ids {
83 match self.load_session(&id).await {
84 Ok(Some(s)) => sessions.push(s),
85 Ok(None) => {}
86 Err(e) => {
87 eprintln!("warning: failed to load session {id}: {e}; skipping");
88 }
89 }
90 }
91 Ok(sessions)
92 }
93
94 async fn delete_session(&self, session_id: &str) -> io::Result<()> {
95 let dir = self.session_dir(session_id);
96 if tfs::metadata(&dir).await.is_ok() {
97 tfs::remove_dir_all(&dir).await?;
98 }
99 Ok(())
100 }
101
102 async fn list_session_ids(&self) -> io::Result<Vec<String>> {
103 let sessions_dir = self.base_dir.join("sessions");
104 if tfs::metadata(&sessions_dir).await.is_err() {
105 return Ok(vec![]);
106 }
107 let mut ids = Vec::new();
108 let mut entries = tfs::read_dir(&sessions_dir).await?;
109 while let Some(entry) = entries.next_entry().await? {
110 if !entry.file_type().await?.is_dir() {
111 continue;
112 }
113 ids.push(entry.file_name().to_string_lossy().to_string());
114 }
115 Ok(ids)
116 }
117
118 async fn append_log_entry(&self, session_id: &str, entry: &LogEntry) -> io::Result<()> {
119 let path = self.log_file(session_id);
120 let mut line = serde_json::to_string(entry)
121 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
122 line.push('\n');
123
124 let mut file = tfs::OpenOptions::new()
125 .create(true)
126 .append(true)
127 .open(&path)
128 .await?;
129 file.write_all(line.as_bytes()).await?;
130 file.sync_data().await?;
131 Ok(())
132 }
133
134 async fn load_log(&self, session_id: &str) -> io::Result<Vec<LogEntry>> {
135 let path = self.log_file(session_id);
136 if tfs::metadata(&path).await.is_err() {
137 return Ok(vec![]);
138 }
139 let content = tfs::read_to_string(&path).await?;
140 let mut entries = Vec::new();
141 for (line_num, line) in content.lines().enumerate() {
142 if line.trim().is_empty() {
143 continue;
144 }
145 match serde_json::from_str::<LogEntry>(line) {
146 Ok(entry) => entries.push(entry),
147 Err(e) => {
148 eprintln!(
149 "warning: failed to parse log entry at {}:{}: {e}; skipping",
150 path.display(),
151 line_num + 1
152 );
153 }
154 }
155 }
156 Ok(entries)
157 }
158
159 async fn replace_log(&self, session_id: &str, entries: &[LogEntry]) -> io::Result<()> {
160 let path = self.log_file(session_id);
161 let mut data = String::new();
162 for entry in entries {
163 let line = serde_json::to_string(entry)
164 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
165 data.push_str(&line);
166 data.push('\n');
167 }
168 let tmp_path = path.with_extension("jsonl.tmp");
169 tfs::write(&tmp_path, data.as_bytes()).await?;
170 tfs::rename(&tmp_path, &path).await
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::log_store::EntryKind;
178
179 use std::collections::HashSet;
180
181 fn sample_session(id: &str) -> Session {
182 Session::builder(id, "macp.mode.decision.v1", "alice")
183 .ttl_expiry(61_000)
184 .ttl_ms(60_000)
185 .started_at_unix_ms(1_000)
186 .mode_state(vec![1, 2, 3])
187 .participants(vec!["alice".into(), "bob".into()])
188 .seen_message_ids(HashSet::from(["m1".into()]))
189 .intent("test intent")
190 .mode_version("1.0.0")
191 .configuration_version("cfg-1")
192 .policy_version("pol-1")
193 .context_id("test-ctx")
194 .roots(vec![macp_pb::pb::Root {
195 uri: "root://1".into(),
196 name: "r1".into(),
197 }])
198 .build()
199 }
200
201 fn sample_entry(id: &str) -> LogEntry {
202 LogEntry {
203 message_id: id.into(),
204 received_at_ms: 1_700_000_000_000,
205 sender: "alice".into(),
206 message_type: "Message".into(),
207 raw_payload: vec![],
208 entry_kind: EntryKind::Incoming,
209 session_id: String::new(),
210 mode: String::new(),
211 macp_version: String::new(),
212 timestamp_unix_ms: 1_700_000_000_000,
213 bound_mode_version: None,
214 semantics_rev: 0,
215 bound_max_suspend_ms: None,
216 compacted_incoming_ordinals: 0,
217 }
218 }
219
220 #[tokio::test]
221 async fn file_backend_session_round_trip() {
222 let dir = tempfile::tempdir().unwrap();
223 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
224
225 let session = sample_session("s1");
226 backend.create_session_storage("s1").await.unwrap();
227 backend.save_session(&session).await.unwrap();
228
229 let loaded = backend.load_session("s1").await.unwrap().unwrap();
230 assert_eq!(loaded.session_id, "s1");
231 assert_eq!(loaded.ttl_ms, 60_000);
232 assert_eq!(loaded.mode_version, "1.0.0");
233 assert!(loaded.seen_message_ids.contains("m1"));
234 assert_eq!(loaded.participants, vec!["alice", "bob"]);
235 }
236
237 #[tokio::test]
238 async fn file_backend_log_append_and_load() {
239 let dir = tempfile::tempdir().unwrap();
240 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
241
242 backend.create_session_storage("s1").await.unwrap();
243 backend
244 .append_log_entry("s1", &sample_entry("m1"))
245 .await
246 .unwrap();
247 backend
248 .append_log_entry("s1", &sample_entry("m2"))
249 .await
250 .unwrap();
251 backend
252 .append_log_entry("s1", &sample_entry("m3"))
253 .await
254 .unwrap();
255
256 let log = backend.load_log("s1").await.unwrap();
257 assert_eq!(log.len(), 3);
258 assert_eq!(log[0].message_id, "m1");
259 assert_eq!(log[1].message_id, "m2");
260 assert_eq!(log[2].message_id, "m3");
261 }
262
263 #[tokio::test]
264 async fn file_backend_load_all_sessions() {
265 let dir = tempfile::tempdir().unwrap();
266 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
267
268 for id in ["s1", "s2", "s3"] {
269 backend.create_session_storage(id).await.unwrap();
270 backend.save_session(&sample_session(id)).await.unwrap();
271 }
272
273 let mut sessions = backend.load_all_sessions().await.unwrap();
274 sessions.sort_by(|a, b| a.session_id.cmp(&b.session_id));
275 assert_eq!(sessions.len(), 3);
276 assert_eq!(sessions[0].session_id, "s1");
277 assert_eq!(sessions[1].session_id, "s2");
278 assert_eq!(sessions[2].session_id, "s3");
279 }
280
281 #[tokio::test]
282 async fn append_only_no_full_rewrite() {
283 let dir = tempfile::tempdir().unwrap();
284 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
285 backend.create_session_storage("s1").await.unwrap();
286
287 for i in 0..100 {
288 backend
289 .append_log_entry("s1", &sample_entry(&format!("m{}", i)))
290 .await
291 .unwrap();
292 }
293
294 let content = fs::read_to_string(backend.log_file("s1")).unwrap();
295 let line_count = content.lines().count();
296 assert_eq!(line_count, 100);
297
298 let log = backend.load_log("s1").await.unwrap();
299 assert_eq!(log.len(), 100);
300 }
301
302 #[tokio::test]
303 async fn write_ordering_log_before_session() {
304 let dir = tempfile::tempdir().unwrap();
305 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
306 backend.create_session_storage("s1").await.unwrap();
307
308 backend
309 .append_log_entry("s1", &sample_entry("m1"))
310 .await
311 .unwrap();
312
313 let log = backend.load_log("s1").await.unwrap();
314 assert_eq!(log.len(), 1);
315 assert_eq!(log[0].message_id, "m1");
316
317 assert!(backend.load_session("s1").await.unwrap().is_none());
318
319 backend.save_session(&sample_session("s1")).await.unwrap();
320 assert!(backend.load_session("s1").await.unwrap().is_some());
321 }
322
323 #[tokio::test]
324 async fn delete_session_removes_directory() {
325 let dir = tempfile::tempdir().unwrap();
326 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
327
328 backend.create_session_storage("s1").await.unwrap();
329 backend.save_session(&sample_session("s1")).await.unwrap();
330 backend
331 .append_log_entry("s1", &sample_entry("m1"))
332 .await
333 .unwrap();
334
335 assert!(backend.load_session("s1").await.unwrap().is_some());
336
337 backend.delete_session("s1").await.unwrap();
338 assert!(backend.load_session("s1").await.unwrap().is_none());
339 assert!(backend.load_log("s1").await.unwrap().is_empty());
340
341 backend.delete_session("s1").await.unwrap();
343 }
344
345 #[tokio::test]
346 async fn list_session_ids_returns_directories() {
347 let dir = tempfile::tempdir().unwrap();
348 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
349
350 for id in ["s1", "s2", "s3"] {
351 backend.create_session_storage(id).await.unwrap();
352 }
353
354 let mut ids = backend.list_session_ids().await.unwrap();
355 ids.sort();
356 assert_eq!(ids, vec!["s1", "s2", "s3"]);
357 }
358
359 #[tokio::test]
360 async fn replace_log_atomically_overwrites() {
361 let dir = tempfile::tempdir().unwrap();
362 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
363 backend.create_session_storage("s1").await.unwrap();
364
365 for i in 0..10 {
366 backend
367 .append_log_entry("s1", &sample_entry(&format!("m{i}")))
368 .await
369 .unwrap();
370 }
371 assert_eq!(backend.load_log("s1").await.unwrap().len(), 10);
372
373 let replacement = vec![sample_entry("checkpoint")];
374 backend.replace_log("s1", &replacement).await.unwrap();
375
376 let log = backend.load_log("s1").await.unwrap();
377 assert_eq!(log.len(), 1);
378 assert_eq!(log[0].message_id, "checkpoint");
379 }
380
381 #[tokio::test]
382 async fn ttl_ms_backward_compat_deserialization() {
383 let dir = tempfile::tempdir().unwrap();
384 let base = dir.path();
385 let backend = FileBackend::new(base.to_path_buf()).unwrap();
386 backend.create_session_storage("s1").await.unwrap();
387
388 let json = serde_json::json!({
389 "session_id": "s1",
390 "state": "Open",
391 "ttl_expiry": 61000,
392 "started_at_unix_ms": 1000,
393 "resolution": null,
394 "mode": "macp.mode.decision.v1",
395 "mode_state": [],
396 "participants": ["alice"],
397 "seen_message_ids": [],
398 "intent": "",
399 "mode_version": "1.0.0",
400 "configuration_version": "cfg",
401 "policy_version": "pol",
402 "context": [],
403 "roots": [],
404 "initiator_sender": "alice"
405 });
406 fs::write(
407 backend.session_file("s1"),
408 serde_json::to_vec_pretty(&json).unwrap(),
409 )
410 .unwrap();
411
412 let loaded = backend.load_session("s1").await.unwrap().unwrap();
413 assert_eq!(loaded.ttl_ms, 60_000);
414 }
415
416 #[tokio::test]
417 async fn load_log_skips_truncated_trailing_line() {
418 let dir = tempfile::tempdir().unwrap();
419 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
420 backend.create_session_storage("s1").await.unwrap();
421
422 backend
423 .append_log_entry("s1", &sample_entry("m1"))
424 .await
425 .unwrap();
426 backend
427 .append_log_entry("s1", &sample_entry("m2"))
428 .await
429 .unwrap();
430
431 let full_line = serde_json::to_string(&sample_entry("m3")).unwrap();
434 let torn = &full_line[..full_line.len() / 2];
435 use std::io::Write;
436 let mut f = fs::OpenOptions::new()
437 .append(true)
438 .open(backend.log_file("s1"))
439 .unwrap();
440 f.write_all(torn.as_bytes()).unwrap();
441 drop(f);
442
443 let log = backend.load_log("s1").await.unwrap();
446 assert_eq!(log.len(), 2);
447 assert_eq!(log[0].message_id, "m1");
448 assert_eq!(log[1].message_id, "m2");
449 }
450
451 #[tokio::test]
452 async fn load_session_errors_on_corrupt_session_json() {
453 let dir = tempfile::tempdir().unwrap();
454 let backend = FileBackend::new(dir.path().to_path_buf()).unwrap();
455 backend.create_session_storage("s1").await.unwrap();
456
457 fs::write(backend.session_file("s1"), b"{not valid json").unwrap();
458
459 let err = backend.load_session("s1").await.unwrap_err();
460 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
461 }
462}