1use crate::atomic_io;
8use crate::errors::{IoOperationKind, StoreError};
9use crate::format_convert::json_to_toml;
10use serde_json::Value as JsonValue;
11use std::fs::{self, File};
12use std::io::Write as IoWrite;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum FormatStrategy {
21 Toml,
23 Json,
25}
26
27#[derive(Debug, Clone)]
29pub struct AtomicWriteConfig {
30 pub retry_count: usize,
32 pub cleanup_tmp_files: bool,
34}
35
36impl Default for AtomicWriteConfig {
37 fn default() -> Self {
38 Self {
39 retry_count: 3,
40 cleanup_tmp_files: true,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LoadBehavior {
51 CreateIfMissing,
53 SaveIfMissing,
55 ErrorIfMissing,
57}
58
59#[derive(Debug, Clone)]
61pub struct FileStorageStrategy {
62 pub format: FormatStrategy,
64 pub atomic_write: AtomicWriteConfig,
66 pub load_behavior: LoadBehavior,
68 pub default_value: Option<JsonValue>,
70}
71
72impl Default for FileStorageStrategy {
73 fn default() -> Self {
74 Self {
75 format: FormatStrategy::Toml,
76 atomic_write: AtomicWriteConfig::default(),
77 load_behavior: LoadBehavior::CreateIfMissing,
78 default_value: None,
79 }
80 }
81}
82
83impl FileStorageStrategy {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn with_format(mut self, format: FormatStrategy) -> Self {
91 self.format = format;
92 self
93 }
94
95 pub fn with_retry_count(mut self, count: usize) -> Self {
97 self.atomic_write.retry_count = count;
98 self
99 }
100
101 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
103 self.atomic_write.cleanup_tmp_files = cleanup;
104 self
105 }
106
107 pub fn with_load_behavior(mut self, behavior: LoadBehavior) -> Self {
109 self.load_behavior = behavior;
110 self
111 }
112
113 pub fn with_default_value(mut self, value: JsonValue) -> Self {
115 self.default_value = Some(value);
116 self
117 }
118}
119
120pub struct FileStorage {
126 path: PathBuf,
127 strategy: FileStorageStrategy,
128}
129
130impl FileStorage {
131 pub fn new(path: PathBuf, strategy: FileStorageStrategy) -> Result<Self, StoreError> {
137 let file_was_missing = !path.exists();
138
139 if file_was_missing {
140 match strategy.load_behavior {
141 LoadBehavior::ErrorIfMissing => {
142 return Err(StoreError::IoError {
143 operation: IoOperationKind::Read,
144 path: path.display().to_string(),
145 context: None,
146 error: "File not found".to_string(),
147 });
148 }
149 LoadBehavior::CreateIfMissing => {
150 }
152 LoadBehavior::SaveIfMissing => {
153 let storage = Self { path, strategy };
155 let content = storage.default_value_as_string()?;
156 storage.write_string(&content)?;
157 return Ok(storage);
158 }
159 }
160 }
161
162 Ok(Self { path, strategy })
163 }
164
165 pub fn read_string(&self) -> Result<String, StoreError> {
169 fs::read_to_string(&self.path).map_err(|e| StoreError::IoError {
170 operation: IoOperationKind::Read,
171 path: self.path.display().to_string(),
172 context: None,
173 error: e.to_string(),
174 })
175 }
176
177 pub fn write_string(&self, content: &str) -> Result<(), StoreError> {
182 if let Some(parent) = self.path.parent() {
184 if !parent.as_os_str().is_empty() && !parent.exists() {
185 fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
186 operation: IoOperationKind::CreateDir,
187 path: parent.display().to_string(),
188 context: Some("parent directory".to_string()),
189 error: e.to_string(),
190 })?;
191 }
192 }
193
194 let tmp_path = atomic_io::get_temp_path(&self.path)?;
195
196 let mut tmp_file = File::create(&tmp_path).map_err(|e| StoreError::IoError {
197 operation: IoOperationKind::Create,
198 path: tmp_path.display().to_string(),
199 context: Some("temporary file".to_string()),
200 error: e.to_string(),
201 })?;
202
203 tmp_file
204 .write_all(content.as_bytes())
205 .map_err(|e| StoreError::IoError {
206 operation: IoOperationKind::Write,
207 path: tmp_path.display().to_string(),
208 context: Some("temporary file".to_string()),
209 error: e.to_string(),
210 })?;
211
212 tmp_file.sync_all().map_err(|e| StoreError::IoError {
213 operation: IoOperationKind::Sync,
214 path: tmp_path.display().to_string(),
215 context: Some("temporary file".to_string()),
216 error: e.to_string(),
217 })?;
218
219 drop(tmp_file);
220
221 atomic_io::atomic_rename(
222 &tmp_path,
223 &self.path,
224 self.strategy.atomic_write.retry_count,
225 )?;
226
227 atomic_io::sync_parent_dir(&self.path)?;
228
229 if self.strategy.atomic_write.cleanup_tmp_files {
230 let _ = atomic_io::cleanup_temp_files(&self.path);
231 }
232
233 Ok(())
234 }
235
236 pub fn path(&self) -> &Path {
238 &self.path
239 }
240
241 pub fn strategy(&self) -> &FileStorageStrategy {
243 &self.strategy
244 }
245
246 fn default_value_as_string(&self) -> Result<String, StoreError> {
252 let json_value = self
253 .strategy
254 .default_value
255 .clone()
256 .unwrap_or(JsonValue::Object(Default::default()));
257
258 match self.strategy.format {
259 FormatStrategy::Json => {
260 serde_json::to_string_pretty(&json_value).map_err(|e| StoreError::IoError {
261 operation: IoOperationKind::Write,
262 path: self.path.display().to_string(),
263 context: Some("serialize default value".to_string()),
264 error: e.to_string(),
265 })
266 }
267 FormatStrategy::Toml => {
268 let toml_value = json_to_toml(&json_value)?;
269 toml::to_string_pretty(&toml_value).map_err(|e| StoreError::IoError {
270 operation: IoOperationKind::Write,
271 path: self.path.display().to_string(),
272 context: Some("serialize default value as toml".to_string()),
273 error: e.to_string(),
274 })
275 }
276 }
277 }
278}
279
280#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::fs;
288 use tempfile::TempDir;
289
290 #[test]
295 fn test_new_creates_file_with_save_if_missing() {
296 let dir = TempDir::new().unwrap();
297 let path = dir.path().join("config.toml");
298
299 let strategy = FileStorageStrategy::new()
300 .with_load_behavior(LoadBehavior::SaveIfMissing)
301 .with_default_value(serde_json::json!({"key": "value"}));
302
303 assert!(!path.exists());
304 let storage = FileStorage::new(path.clone(), strategy).unwrap();
305 assert!(path.exists(), "file must be created for SaveIfMissing");
306 assert_eq!(storage.path(), path.as_path());
307 }
308
309 #[test]
310 fn test_new_no_file_create_if_missing() {
311 let dir = TempDir::new().unwrap();
312 let path = dir.path().join("missing.toml");
313
314 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
315
316 let storage = FileStorage::new(path.clone(), strategy).unwrap();
317 assert!(!path.exists());
319 assert_eq!(storage.path(), path.as_path());
320 }
321
322 #[test]
323 fn test_new_error_if_missing() {
324 let dir = TempDir::new().unwrap();
325 let path = dir.path().join("absent.toml");
326
327 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
328
329 let result = FileStorage::new(path, strategy);
330 assert!(result.is_err());
331 assert!(matches!(
332 result,
333 Err(StoreError::IoError {
334 operation: IoOperationKind::Read,
335 ..
336 })
337 ));
338 }
339
340 #[test]
345 fn test_read_string_returns_file_content() {
346 let dir = TempDir::new().unwrap();
347 let path = dir.path().join("data.json");
348 fs::write(&path, r#"{"hello":"world"}"#).unwrap();
349
350 let strategy = FileStorageStrategy::new()
351 .with_format(FormatStrategy::Json)
352 .with_load_behavior(LoadBehavior::ErrorIfMissing);
353
354 let storage = FileStorage::new(path, strategy).unwrap();
355 let content = storage.read_string().unwrap();
356 assert_eq!(content, r#"{"hello":"world"}"#);
357 }
358
359 #[test]
360 fn test_write_string_creates_and_reads_back() {
361 let dir = TempDir::new().unwrap();
362 let path = dir.path().join("out.json");
363
364 let strategy = FileStorageStrategy::new()
365 .with_format(FormatStrategy::Json)
366 .with_load_behavior(LoadBehavior::CreateIfMissing);
367
368 let storage = FileStorage::new(path.clone(), strategy).unwrap();
369 storage.write_string(r#"{"x":1}"#).unwrap();
370
371 let back = storage.read_string().unwrap();
372 assert_eq!(back, r#"{"x":1}"#);
373 }
374
375 #[test]
376 fn test_write_string_creates_parent_dirs() {
377 let dir = TempDir::new().unwrap();
378 let path = dir.path().join("a").join("b").join("c.toml");
379
380 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
381
382 let storage = FileStorage::new(path.clone(), strategy).unwrap();
383 storage.write_string("").unwrap();
384 assert!(path.exists());
385 }
386
387 #[test]
392 fn test_atomic_write_no_tmp_left() {
393 let dir = TempDir::new().unwrap();
394 let path = dir.path().join("atomic.toml");
395 let strategy = FileStorageStrategy::default();
396
397 let storage = FileStorage::new(path.clone(), strategy).unwrap();
398 storage.write_string("hello = true\n").unwrap();
399
400 let tmp_files: Vec<_> = fs::read_dir(dir.path())
401 .unwrap()
402 .filter_map(|e| e.ok())
403 .filter(|e| {
404 e.file_name()
405 .to_string_lossy()
406 .starts_with(".atomic.toml.tmp")
407 })
408 .collect();
409 assert_eq!(tmp_files.len(), 0, "no temp files should remain");
410 }
411
412 #[test]
417 fn test_cleanup_keeps_fresh_tmp_files() {
418 let dir = TempDir::new().unwrap();
422 let path = dir.path().join("cfg.toml");
423
424 let fake1 = dir.path().join(".cfg.toml.tmp.11111-0");
425 let fake2 = dir.path().join(".cfg.toml.tmp.22222-0");
426 fs::write(&fake1, "in-flight1").unwrap();
427 fs::write(&fake2, "in-flight2").unwrap();
428
429 let strategy = FileStorageStrategy::default();
430 let storage = FileStorage::new(path.clone(), strategy).unwrap();
431 storage.write_string("cfg = true\n").unwrap();
432
433 assert!(fake1.exists(), "fresh tmp 1 must be kept");
434 assert!(fake2.exists(), "fresh tmp 2 must be kept");
435 }
436
437 #[test]
438 fn test_no_cleanup_keeps_stale_tmp_files() {
439 let dir = TempDir::new().unwrap();
440 let path = dir.path().join("no_clean.toml");
441 let fake = dir.path().join(".no_clean.toml.tmp.99999-0");
442 fs::write(&fake, "stale").unwrap();
443
444 let strategy = FileStorageStrategy::new().with_cleanup(false);
445 let storage = FileStorage::new(path.clone(), strategy).unwrap();
446 storage.write_string("x = 1\n").unwrap();
447
448 assert!(fake.exists(), "stale tmp must remain when cleanup=false");
449 }
450
451 #[test]
456 fn test_save_if_missing_json_format() {
457 let dir = TempDir::new().unwrap();
458 let path = dir.path().join("data.json");
459
460 let strategy = FileStorageStrategy::new()
461 .with_format(FormatStrategy::Json)
462 .with_load_behavior(LoadBehavior::SaveIfMissing)
463 .with_default_value(serde_json::json!({"items": []}));
464
465 FileStorage::new(path.clone(), strategy).unwrap();
466 let content = fs::read_to_string(&path).unwrap();
467 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
469 assert!(parsed.get("items").is_some());
470 }
471
472 #[test]
473 fn test_save_if_missing_toml_format() {
474 let dir = TempDir::new().unwrap();
475 let path = dir.path().join("data.toml");
476
477 let strategy = FileStorageStrategy::new()
478 .with_format(FormatStrategy::Toml)
479 .with_load_behavior(LoadBehavior::SaveIfMissing)
480 .with_default_value(serde_json::json!({"name": "alice"}));
481
482 FileStorage::new(path.clone(), strategy).unwrap();
483 let content = fs::read_to_string(&path).unwrap();
484 let parsed: toml::Value = toml::from_str(&content).unwrap();
486 assert!(parsed.get("name").is_some());
487 }
488
489 #[test]
494 fn test_strategy_builder() {
495 let s = FileStorageStrategy::new()
496 .with_format(FormatStrategy::Json)
497 .with_retry_count(5)
498 .with_cleanup(false)
499 .with_load_behavior(LoadBehavior::ErrorIfMissing);
500
501 assert_eq!(s.format, FormatStrategy::Json);
502 assert_eq!(s.atomic_write.retry_count, 5);
503 assert!(!s.atomic_write.cleanup_tmp_files);
504 assert_eq!(s.load_behavior, LoadBehavior::ErrorIfMissing);
505 }
506
507 #[test]
508 fn test_atomic_write_config_default() {
509 let cfg = AtomicWriteConfig::default();
510 assert_eq!(cfg.retry_count, 3);
511 assert!(cfg.cleanup_tmp_files);
512 }
513
514 #[test]
515 fn test_format_strategy_equality() {
516 assert_eq!(FormatStrategy::Toml, FormatStrategy::Toml);
517 assert_ne!(FormatStrategy::Toml, FormatStrategy::Json);
518 }
519
520 #[test]
521 fn test_load_behavior_equality() {
522 assert_eq!(LoadBehavior::CreateIfMissing, LoadBehavior::CreateIfMissing);
523 assert_ne!(LoadBehavior::CreateIfMissing, LoadBehavior::ErrorIfMissing);
524 }
525}