Skip to main content

fluers_runtime/
json_file_adapter.rs

1//! JSON-file persistence adapter.
2
3use std::{io::ErrorKind, path::PathBuf};
4
5use async_trait::async_trait;
6use serde_json::Value;
7
8use crate::persistence::{PersistenceAdapter, PersistenceError, Result};
9
10/// A JSON-file persistence backend. Each session is stored as
11/// `<dir>/<id>.json`. Suitable for local single-node deployments where
12/// Postgres (MVP 4) is not available.
13pub struct JsonFileAdapter {
14    dir: PathBuf,
15}
16
17impl JsonFileAdapter {
18    /// Create an adapter rooted at `dir`.
19    ///
20    /// The directory is created lazily when a session is saved.
21    pub fn new(dir: impl Into<PathBuf>) -> Self {
22        Self { dir: dir.into() }
23    }
24
25    fn session_path(&self, id: &str) -> PathBuf {
26        self.dir.join(format!("{id}.json"))
27    }
28
29    fn temp_session_path(&self, id: &str) -> PathBuf {
30        self.dir.join(format!("{id}.json.tmp"))
31    }
32}
33
34#[async_trait]
35impl PersistenceAdapter for JsonFileAdapter {
36    async fn save_session(&self, id: &str, data: &Value) -> Result<()> {
37        tokio::fs::create_dir_all(&self.dir).await.map_err(|err| {
38            PersistenceError::Backend(format!(
39                "failed to create session directory {}: {err}",
40                self.dir.display()
41            ))
42        })?;
43
44        let bytes = serde_json::to_vec_pretty(data).map_err(|err| {
45            PersistenceError::Backend(format!("failed to serialize session {id}: {err}"))
46        })?;
47
48        let temp_path = self.temp_session_path(id);
49        tokio::fs::write(&temp_path, bytes).await.map_err(|err| {
50            PersistenceError::Backend(format!(
51                "failed to write session temp file {}: {err}",
52                temp_path.display()
53            ))
54        })?;
55
56        let session_path = self.session_path(id);
57        tokio::fs::rename(&temp_path, &session_path)
58            .await
59            .map_err(|err| {
60                PersistenceError::Backend(format!(
61                    "failed to replace session file {} with {}: {err}",
62                    session_path.display(),
63                    temp_path.display()
64                ))
65            })?;
66
67        Ok(())
68    }
69
70    async fn load_session(&self, id: &str) -> Result<Option<Value>> {
71        let session_path = self.session_path(id);
72        let bytes = match tokio::fs::read(&session_path).await {
73            Ok(bytes) => bytes,
74            Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
75            Err(err) => {
76                return Err(PersistenceError::Backend(format!(
77                    "failed to read session file {}: {err}",
78                    session_path.display()
79                )));
80            }
81        };
82
83        let value = serde_json::from_slice(&bytes).map_err(|err| {
84            PersistenceError::Backend(format!(
85                "failed to parse session file {}: {err}",
86                session_path.display()
87            ))
88        })?;
89
90        Ok(Some(value))
91    }
92
93    async fn list_sessions(&self) -> Result<Vec<String>> {
94        let mut entries = match tokio::fs::read_dir(&self.dir).await {
95            Ok(entries) => entries,
96            Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
97            Err(err) => {
98                return Err(PersistenceError::Backend(format!(
99                    "failed to read session directory {}: {err}",
100                    self.dir.display()
101                )));
102            }
103        };
104
105        let mut sessions = Vec::new();
106        while let Some(entry) = entries.next_entry().await.map_err(|err| {
107            PersistenceError::Backend(format!(
108                "failed to read session directory entry in {}: {err}",
109                self.dir.display()
110            ))
111        })? {
112            let file_type = entry.file_type().await.map_err(|err| {
113                PersistenceError::Backend(format!(
114                    "failed to read file type for {}: {err}",
115                    entry.path().display()
116                ))
117            })?;
118
119            if !file_type.is_file() {
120                continue;
121            }
122
123            let file_name = entry.file_name();
124            let Some(file_name) = file_name.to_str() else {
125                continue;
126            };
127            let Some(id) = file_name.strip_suffix(".json") else {
128                continue;
129            };
130            sessions.push(id.to_owned());
131        }
132
133        Ok(sessions)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use serde_json::json;
140    use tempfile::tempdir;
141
142    use super::*;
143
144    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
145
146    #[tokio::test]
147    async fn save_then_load_roundtrips() -> TestResult {
148        let dir = tempdir()?;
149        let adapter = JsonFileAdapter::new(dir.path());
150        let data = json!({ "hello": "world" });
151
152        adapter.save_session("session-1", &data).await?;
153        let loaded = adapter.load_session("session-1").await?;
154
155        assert_eq!(loaded, Some(data));
156        Ok(())
157    }
158
159    #[tokio::test]
160    async fn load_missing_returns_none() -> TestResult {
161        let dir = tempdir()?;
162        let adapter = JsonFileAdapter::new(dir.path());
163
164        let loaded = adapter.load_session("missing").await?;
165
166        assert_eq!(loaded, None);
167        Ok(())
168    }
169
170    #[tokio::test]
171    async fn list_sessions_returns_ids() -> TestResult {
172        let dir = tempdir()?;
173        let adapter = JsonFileAdapter::new(dir.path());
174
175        adapter
176            .save_session("session-a", &json!({ "a": 1 }))
177            .await?;
178        adapter
179            .save_session("session-b", &json!({ "b": 2 }))
180            .await?;
181
182        let mut sessions = adapter.list_sessions().await?;
183        sessions.sort();
184
185        assert_eq!(
186            sessions,
187            vec!["session-a".to_owned(), "session-b".to_owned()]
188        );
189        Ok(())
190    }
191
192    #[tokio::test]
193    async fn list_sessions_empty_dir_returns_empty() -> TestResult {
194        let dir = tempdir()?;
195        let adapter = JsonFileAdapter::new(dir.path());
196
197        let sessions = adapter.list_sessions().await?;
198
199        assert!(sessions.is_empty());
200        Ok(())
201    }
202
203    #[tokio::test]
204    async fn save_is_atomic() -> TestResult {
205        let dir = tempdir()?;
206        let adapter = JsonFileAdapter::new(dir.path());
207        let first = json!({ "version": 1 });
208        let second = json!({ "version": 2 });
209
210        adapter.save_session("session-1", &first).await?;
211        adapter.save_session("session-1", &second).await?;
212        let loaded = adapter.load_session("session-1").await?;
213
214        assert_eq!(loaded, Some(second));
215        Ok(())
216    }
217}