Skip to main content

apiari_common/
state.rs

1//! Generic JSON state persistence (load/save).
2//!
3//! Provides [`load_state`] and [`save_state`] for any type that implements
4//! serde's `Serialize` / `DeserializeOwned`. State files are written atomically
5//! (write to a temp file, then rename) so a crash mid-write never corrupts the
6//! on-disk state.
7
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use std::io;
11use std::path::Path;
12
13/// Load state from a JSON file.
14///
15/// - If the file does not exist, returns the type's `Default` value.
16/// - If the file exists but cannot be parsed, returns an error.
17///
18/// # Errors
19///
20/// Returns `io::Error` if the file exists but cannot be read or parsed.
21pub fn load_state<T: DeserializeOwned + Default>(path: &Path) -> io::Result<T> {
22    match std::fs::read_to_string(path) {
23        Ok(data) => {
24            serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
25        }
26        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(T::default()),
27        Err(e) => Err(e),
28    }
29}
30
31/// Save state to a JSON file atomically.
32///
33/// Writes to a temporary file in the same directory, then renames it into
34/// place. This guarantees that the state file is always either the old
35/// version or the new version, never a partially-written mix.
36///
37/// Parent directories are created automatically if they don't exist.
38///
39/// # Errors
40///
41/// Returns `io::Error` if serialization, directory creation, writing,
42/// or renaming fails.
43pub fn save_state<T: Serialize>(path: &Path, state: &T) -> io::Result<()> {
44    if let Some(parent) = path.parent() {
45        std::fs::create_dir_all(parent)?;
46    }
47
48    let data = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
49
50    // Write to a sibling temp file, then atomically rename.
51    let tmp_path = path.with_extension("json.tmp");
52    std::fs::write(&tmp_path, &data)?;
53    std::fs::rename(&tmp_path, path)?;
54
55    Ok(())
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use serde::{Deserialize, Serialize};
62    use std::fs;
63
64    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
65    struct TestState {
66        counter: u64,
67        name: String,
68    }
69
70    #[test]
71    fn test_save_and_load() {
72        let dir = std::env::temp_dir().join("apiari-state-test-save-load");
73        let _ = fs::remove_dir_all(&dir);
74        fs::create_dir_all(&dir).unwrap();
75        let path = dir.join("state.json");
76
77        let state = TestState {
78            counter: 42,
79            name: "test".into(),
80        };
81
82        save_state(&path, &state).unwrap();
83        let loaded: TestState = load_state(&path).unwrap();
84        assert_eq!(loaded, state);
85
86        let _ = fs::remove_dir_all(&dir);
87    }
88
89    #[test]
90    fn test_load_missing_returns_default() {
91        let path = std::env::temp_dir().join("apiari-state-test-missing-file.json");
92        let _ = fs::remove_file(&path);
93
94        let loaded: TestState = load_state(&path).unwrap();
95        assert_eq!(loaded, TestState::default());
96    }
97
98    #[test]
99    fn test_save_creates_parent_dirs() {
100        let dir = std::env::temp_dir().join("apiari-state-test-parents/a/b/c");
101        let _ = fs::remove_dir_all(std::env::temp_dir().join("apiari-state-test-parents"));
102        let path = dir.join("state.json");
103
104        let state = TestState {
105            counter: 1,
106            name: "nested".into(),
107        };
108
109        save_state(&path, &state).unwrap();
110        assert!(path.exists());
111
112        let loaded: TestState = load_state(&path).unwrap();
113        assert_eq!(loaded, state);
114
115        let _ = fs::remove_dir_all(std::env::temp_dir().join("apiari-state-test-parents"));
116    }
117
118    #[test]
119    fn test_atomic_write_no_temp_file_left() {
120        let dir = std::env::temp_dir().join("apiari-state-test-atomic");
121        let _ = fs::remove_dir_all(&dir);
122        fs::create_dir_all(&dir).unwrap();
123        let path = dir.join("state.json");
124        let tmp_path = dir.join("state.json.tmp");
125
126        let state = TestState {
127            counter: 99,
128            name: "atomic".into(),
129        };
130
131        save_state(&path, &state).unwrap();
132
133        // The temp file should have been renamed away
134        assert!(path.exists());
135        assert!(!tmp_path.exists());
136
137        let _ = fs::remove_dir_all(&dir);
138    }
139
140    #[test]
141    fn test_load_corrupt_file_returns_error() {
142        let dir = std::env::temp_dir().join("apiari-state-test-corrupt");
143        let _ = fs::remove_dir_all(&dir);
144        fs::create_dir_all(&dir).unwrap();
145        let path = dir.join("state.json");
146
147        fs::write(&path, "not valid json!!!").unwrap();
148
149        let result: io::Result<TestState> = load_state(&path);
150        assert!(result.is_err());
151
152        let _ = fs::remove_dir_all(&dir);
153    }
154
155    #[test]
156    fn test_overwrite_existing() {
157        let dir = std::env::temp_dir().join("apiari-state-test-overwrite");
158        let _ = fs::remove_dir_all(&dir);
159        fs::create_dir_all(&dir).unwrap();
160        let path = dir.join("state.json");
161
162        let state1 = TestState {
163            counter: 1,
164            name: "first".into(),
165        };
166        save_state(&path, &state1).unwrap();
167
168        let state2 = TestState {
169            counter: 2,
170            name: "second".into(),
171        };
172        save_state(&path, &state2).unwrap();
173
174        let loaded: TestState = load_state(&path).unwrap();
175        assert_eq!(loaded, state2);
176
177        let _ = fs::remove_dir_all(&dir);
178    }
179}