fluers_runtime/
json_file_adapter.rs1use std::{io::ErrorKind, path::PathBuf};
4
5use async_trait::async_trait;
6use serde_json::Value;
7
8use crate::persistence::{PersistenceAdapter, PersistenceError, Result};
9
10pub struct JsonFileAdapter {
14 dir: PathBuf,
15}
16
17impl JsonFileAdapter {
18 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}