1use serde::Serialize;
9use serde::de::DeserializeOwned;
10use std::io;
11use std::path::Path;
12
13pub 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
31pub 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 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 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}