Skip to main content

mecha10_core/state/
filesystem_impl.rs

1//! `StateManager` implementation for [`super::FilesystemStateManager`].
2
3use super::{FilesystemStateManager, StateManager, StateOptions, StateSnapshot, VersionedState};
4use crate::error::{Mecha10Error, Result};
5use crate::prelude::now_micros;
6use async_trait::async_trait;
7use serde::{de::DeserializeOwned, Serialize};
8use std::path::Path;
9use tokio::fs;
10
11/// Lists filenames directly inside `dir` whose name ends with `suffix`.
12///
13/// Shared by `list_nodes` (scanning `base_path` for `*.state.json`) and `list_snapshots`
14/// (scanning `snapshots/` for `*.meta.json`), which otherwise repeat the same
15/// read-dir/next-entry/filename-suffix-filter loop. `dir_label` is folded into the I/O error
16/// message so each call site keeps its own wording (e.g. "state directory" vs "snapshots
17/// directory").
18async fn filenames_with_suffix(dir: &Path, suffix: &str, dir_label: &str) -> Result<Vec<String>> {
19    let mut names = Vec::new();
20    let mut entries = fs::read_dir(dir).await.map_err(|e| Mecha10Error::IoError {
21        message: format!("Failed to read {}: {}", dir_label, e),
22        suggestion: "Check directory permissions".to_string(),
23    })?;
24
25    while let Some(entry) = entries.next_entry().await.map_err(|e| Mecha10Error::IoError {
26        message: format!("Failed to read directory entry: {}", e),
27        suggestion: "Check directory permissions".to_string(),
28    })? {
29        let path = entry.path();
30        if let Some(filename) = path.file_name() {
31            let filename = filename.to_string_lossy().into_owned();
32            if filename.ends_with(suffix) {
33                names.push(filename);
34            }
35        }
36    }
37
38    Ok(names)
39}
40
41#[async_trait]
42impl StateManager for FilesystemStateManager {
43    async fn save_with_options<T: Serialize + Send + Sync>(
44        &self,
45        node_id: &str,
46        state: &T,
47        options: StateOptions,
48    ) -> Result<()> {
49        let versioned = if let Some(ttl) = options.ttl {
50            VersionedState::with_ttl(state, options.version, ttl)
51        } else {
52            VersionedState::new(state, options.version)
53        };
54
55        let path = self.state_path(node_id);
56
57        if options.create_backup && path.exists() {
58            let backup_path = path.with_extension("state.json.backup");
59            fs::copy(&path, backup_path).await.ok();
60        }
61
62        let json = serde_json::to_string_pretty(&versioned).map_err(|e| Mecha10Error::SerializationError {
63            message: format!("Failed to serialize state: {}", e),
64            suggestion: "Ensure state type implements Serialize".to_string(),
65        })?;
66
67        fs::write(&path, json).await.map_err(|e| Mecha10Error::IoError {
68            message: format!("Failed to write state file: {}", e),
69            suggestion: "Check file permissions".to_string(),
70        })?;
71
72        Ok(())
73    }
74
75    async fn load_versioned<T: DeserializeOwned + Send + Sync>(
76        &self,
77        node_id: &str,
78    ) -> Result<Option<VersionedState<T>>> {
79        let path = self.state_path(node_id);
80
81        if !path.exists() {
82            return Ok(None);
83        }
84
85        let json = fs::read_to_string(&path).await.map_err(|e| Mecha10Error::IoError {
86            message: format!("Failed to read state file: {}", e),
87            suggestion: "Check file permissions".to_string(),
88        })?;
89
90        let versioned: VersionedState<T> =
91            serde_json::from_str(&json).map_err(|e| Mecha10Error::SerializationError {
92                message: format!("Failed to deserialize state: {}", e),
93                suggestion: "State may be corrupted or type mismatch".to_string(),
94            })?;
95
96        if versioned.is_expired() {
97            return Ok(None);
98        }
99
100        Ok(Some(versioned))
101    }
102
103    async fn delete(&self, node_id: &str) -> Result<()> {
104        let path = self.state_path(node_id);
105
106        if path.exists() {
107            fs::remove_file(&path).await.map_err(|e| Mecha10Error::IoError {
108                message: format!("Failed to delete state file: {}", e),
109                suggestion: "Check file permissions".to_string(),
110            })?;
111        }
112
113        Ok(())
114    }
115
116    async fn exists(&self, node_id: &str) -> Result<bool> {
117        Ok(self.state_path(node_id).exists())
118    }
119
120    async fn list_nodes(&self) -> Result<Vec<String>> {
121        let filenames = filenames_with_suffix(&self.base_path, ".state.json", "state directory").await?;
122
123        Ok(filenames
124            .into_iter()
125            .map(|filename| filename.trim_end_matches(".state.json").to_string())
126            .collect())
127    }
128
129    async fn size(&self, node_id: &str) -> Result<Option<usize>> {
130        let path = self.state_path(node_id);
131
132        if !path.exists() {
133            return Ok(None);
134        }
135
136        let metadata = fs::metadata(&path).await.map_err(|e| Mecha10Error::IoError {
137            message: format!("Failed to get file metadata: {}", e),
138            suggestion: "Check file permissions".to_string(),
139        })?;
140
141        Ok(Some(metadata.len() as usize))
142    }
143
144    async fn create_snapshot(&self, node_id: &str, description: Option<String>) -> Result<StateSnapshot> {
145        let state_path = self.state_path(node_id);
146
147        if !state_path.exists() {
148            return Err(Mecha10Error::Configuration(format!("No state for node: {}", node_id)));
149        }
150
151        let snapshot_id = uuid::Uuid::new_v4().to_string();
152
153        let snapshots_dir = self.base_path.join("snapshots");
154        fs::create_dir_all(&snapshots_dir)
155            .await
156            .map_err(|e| Mecha10Error::IoError {
157                message: format!("Failed to create snapshots directory: {}", e),
158                suggestion: "Check directory permissions".to_string(),
159            })?;
160
161        let snapshot_path = self.snapshot_path(&snapshot_id);
162        fs::copy(&state_path, &snapshot_path)
163            .await
164            .map_err(|e| Mecha10Error::IoError {
165                message: format!("Failed to create snapshot: {}", e),
166                suggestion: "Check file permissions".to_string(),
167            })?;
168
169        let size_bytes = self.size(node_id).await?.unwrap_or(0);
170
171        let snapshot = StateSnapshot {
172            id: snapshot_id.clone(),
173            node_id: node_id.to_string(),
174            timestamp: now_micros(),
175            description,
176            size_bytes,
177        };
178
179        let meta_path = self.snapshot_meta_path(&snapshot_id);
180        let meta_json = serde_json::to_string_pretty(&snapshot).unwrap();
181        fs::write(&meta_path, meta_json)
182            .await
183            .map_err(|e| Mecha10Error::IoError {
184                message: format!("Failed to write snapshot metadata: {}", e),
185                suggestion: "Check file permissions".to_string(),
186            })?;
187
188        Ok(snapshot)
189    }
190
191    async fn restore_snapshot(&self, snapshot_id: &str) -> Result<()> {
192        let snapshot_path = self.snapshot_path(snapshot_id);
193        let meta_path = self.snapshot_meta_path(snapshot_id);
194
195        if !snapshot_path.exists() || !meta_path.exists() {
196            return Err(Mecha10Error::Configuration(format!(
197                "Snapshot not found: {}",
198                snapshot_id
199            )));
200        }
201
202        let meta_json = fs::read_to_string(&meta_path)
203            .await
204            .map_err(|e| Mecha10Error::IoError {
205                message: format!("Failed to read snapshot metadata: {}", e),
206                suggestion: "Check file permissions".to_string(),
207            })?;
208
209        let snapshot: StateSnapshot =
210            serde_json::from_str(&meta_json).map_err(|e| Mecha10Error::SerializationError {
211                message: format!("Failed to deserialize snapshot metadata: {}", e),
212                suggestion: "Snapshot metadata may be corrupted".to_string(),
213            })?;
214
215        let state_path = self.state_path(&snapshot.node_id);
216        fs::copy(&snapshot_path, &state_path)
217            .await
218            .map_err(|e| Mecha10Error::IoError {
219                message: format!("Failed to restore snapshot: {}", e),
220                suggestion: "Check file permissions".to_string(),
221            })?;
222
223        Ok(())
224    }
225
226    async fn list_snapshots(&self, node_id: &str) -> Result<Vec<StateSnapshot>> {
227        let snapshots_dir = self.base_path.join("snapshots");
228
229        if !snapshots_dir.exists() {
230            return Ok(Vec::new());
231        }
232
233        let mut snapshots = Vec::new();
234        let filenames = filenames_with_suffix(&snapshots_dir, ".meta.json", "snapshots directory").await?;
235
236        for filename in filenames {
237            let json = fs::read_to_string(snapshots_dir.join(filename)).await.ok();
238            if let Some(json) = json {
239                if let Ok(snapshot) = serde_json::from_str::<StateSnapshot>(&json) {
240                    if snapshot.node_id == node_id {
241                        snapshots.push(snapshot);
242                    }
243                }
244            }
245        }
246
247        snapshots.sort_by_key(|s| s.timestamp);
248        snapshots.reverse();
249
250        Ok(snapshots)
251    }
252
253    async fn delete_snapshot(&self, snapshot_id: &str) -> Result<()> {
254        let snapshot_path = self.snapshot_path(snapshot_id);
255        let meta_path = self.snapshot_meta_path(snapshot_id);
256
257        if snapshot_path.exists() {
258            fs::remove_file(&snapshot_path).await.ok();
259        }
260
261        if meta_path.exists() {
262            fs::remove_file(&meta_path).await.ok();
263        }
264
265        Ok(())
266    }
267
268    async fn compare_and_swap<T: Serialize + DeserializeOwned + Send + Sync>(
269        &self,
270        node_id: &str,
271        expected: &T,
272        new: &T,
273    ) -> Result<bool> {
274        let path = self.state_path(node_id);
275
276        if !path.exists() {
277            return Ok(false);
278        }
279
280        let json = fs::read_to_string(&path).await.map_err(|e| Mecha10Error::IoError {
281            message: format!("Failed to read state file: {}", e),
282            suggestion: "Check file permissions".to_string(),
283        })?;
284
285        let current: VersionedState<T> = serde_json::from_str(&json).map_err(|e| Mecha10Error::SerializationError {
286            message: format!("Failed to deserialize for CAS: {}", e),
287            suggestion: "State may be corrupted".to_string(),
288        })?;
289
290        let expected_json = serde_json::to_string(expected).unwrap();
291        let current_json = serde_json::to_string(&current.data).unwrap();
292
293        if expected_json == current_json {
294            let versioned = VersionedState::new(new, current.version + 1);
295            let new_json = serde_json::to_string_pretty(&versioned).unwrap();
296            fs::write(&path, new_json).await.map_err(|e| Mecha10Error::IoError {
297                message: format!("Failed to write state file: {}", e),
298                suggestion: "Check file permissions".to_string(),
299            })?;
300            Ok(true)
301        } else {
302            Ok(false)
303        }
304    }
305
306    async fn clear_expired(&self) -> Result<usize> {
307        let nodes = self.list_nodes().await?;
308        let mut count = 0;
309
310        for node_id in nodes {
311            if let Ok(Some(versioned)) = self.load_versioned::<serde_json::Value>(&node_id).await {
312                if versioned.is_expired() {
313                    self.delete(&node_id).await?;
314                    count += 1;
315                }
316            }
317        }
318
319        Ok(count)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[tokio::test]
328    async fn filenames_with_suffix_returns_only_matching_names() {
329        let dir = tempfile::tempdir().expect("tempdir");
330        fs::write(dir.path().join("a.state.json"), b"{}").await.unwrap();
331        fs::write(dir.path().join("b.state.json"), b"{}").await.unwrap();
332        fs::write(dir.path().join("c.meta.json"), b"{}").await.unwrap();
333        fs::write(dir.path().join("d.txt"), b"ignored").await.unwrap();
334
335        let mut names = filenames_with_suffix(dir.path(), ".state.json", "state directory")
336            .await
337            .unwrap();
338        names.sort();
339
340        assert_eq!(names, vec!["a.state.json".to_string(), "b.state.json".to_string()]);
341    }
342
343    #[tokio::test]
344    async fn filenames_with_suffix_returns_empty_for_empty_dir() {
345        let dir = tempfile::tempdir().expect("tempdir");
346
347        let names = filenames_with_suffix(dir.path(), ".meta.json", "snapshots directory")
348            .await
349            .unwrap();
350
351        assert!(names.is_empty());
352    }
353
354    #[tokio::test]
355    async fn filenames_with_suffix_errors_on_missing_dir() {
356        let dir = tempfile::tempdir().expect("tempdir");
357        let missing = dir.path().join("does-not-exist");
358
359        let err = filenames_with_suffix(&missing, ".state.json", "state directory")
360            .await
361            .unwrap_err();
362
363        assert!(err.to_string().contains("state directory"));
364    }
365}