1use crate::atomic_io;
7use crate::errors::{IoOperationKind, StoreError};
8use crate::format_convert::json_to_toml;
9use serde_json::Value as JsonValue;
10use std::fs::{self, File};
11use std::io::Write as IoWrite;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FormatStrategy {
17 Toml,
19 Json,
21}
22
23#[derive(Debug, Clone)]
25pub struct AtomicWriteConfig {
26 pub retry_count: usize,
28 pub cleanup_tmp_files: bool,
30}
31
32impl Default for AtomicWriteConfig {
33 fn default() -> Self {
34 Self {
35 retry_count: 3,
36 cleanup_tmp_files: true,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum LoadBehavior {
44 CreateIfMissing,
46 SaveIfMissing,
48 ErrorIfMissing,
50}
51
52#[derive(Debug, Clone)]
54pub struct FileStorageStrategy {
55 pub format: FormatStrategy,
57 pub atomic_write: AtomicWriteConfig,
59 pub load_behavior: LoadBehavior,
61 pub default_value: Option<JsonValue>,
63}
64
65impl Default for FileStorageStrategy {
66 fn default() -> Self {
67 Self {
68 format: FormatStrategy::Toml,
69 atomic_write: AtomicWriteConfig::default(),
70 load_behavior: LoadBehavior::CreateIfMissing,
71 default_value: None,
72 }
73 }
74}
75
76impl FileStorageStrategy {
77 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn with_format(mut self, format: FormatStrategy) -> Self {
84 self.format = format;
85 self
86 }
87
88 pub fn with_retry_count(mut self, count: usize) -> Self {
90 self.atomic_write.retry_count = count;
91 self
92 }
93
94 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
96 self.atomic_write.cleanup_tmp_files = cleanup;
97 self
98 }
99
100 pub fn with_load_behavior(mut self, behavior: LoadBehavior) -> Self {
102 self.load_behavior = behavior;
103 self
104 }
105
106 pub fn with_default_value(mut self, value: JsonValue) -> Self {
108 self.default_value = Some(value);
109 self
110 }
111}
112
113pub struct FileStorage {
119 path: PathBuf,
120 strategy: FileStorageStrategy,
121}
122
123impl FileStorage {
124 pub fn new(path: PathBuf, strategy: FileStorageStrategy) -> Result<Self, StoreError> {
130 let file_was_missing = !path.exists();
131
132 if file_was_missing {
133 match strategy.load_behavior {
134 LoadBehavior::ErrorIfMissing => {
135 return Err(StoreError::IoError {
136 operation: IoOperationKind::Read,
137 path: path.display().to_string(),
138 context: None,
139 error: "File not found".to_string(),
140 });
141 }
142 LoadBehavior::CreateIfMissing => {
143 }
145 LoadBehavior::SaveIfMissing => {
146 let storage = Self { path, strategy };
148 let content = storage.default_value_as_string()?;
149 storage.write_string(&content)?;
150 return Ok(storage);
151 }
152 }
153 }
154
155 Ok(Self { path, strategy })
156 }
157
158 pub fn read_string(&self) -> Result<String, StoreError> {
162 fs::read_to_string(&self.path).map_err(|e| StoreError::IoError {
163 operation: IoOperationKind::Read,
164 path: self.path.display().to_string(),
165 context: None,
166 error: e.to_string(),
167 })
168 }
169
170 pub fn write_string(&self, content: &str) -> Result<(), StoreError> {
175 if let Some(parent) = self.path.parent() {
177 if !parent.as_os_str().is_empty() && !parent.exists() {
178 fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
179 operation: IoOperationKind::CreateDir,
180 path: parent.display().to_string(),
181 context: Some("parent directory".to_string()),
182 error: e.to_string(),
183 })?;
184 }
185 }
186
187 let tmp_path = atomic_io::get_temp_path(&self.path)?;
188
189 let mut tmp_file = File::create(&tmp_path).map_err(|e| StoreError::IoError {
190 operation: IoOperationKind::Create,
191 path: tmp_path.display().to_string(),
192 context: Some("temporary file".to_string()),
193 error: e.to_string(),
194 })?;
195
196 tmp_file
197 .write_all(content.as_bytes())
198 .map_err(|e| StoreError::IoError {
199 operation: IoOperationKind::Write,
200 path: tmp_path.display().to_string(),
201 context: Some("temporary file".to_string()),
202 error: e.to_string(),
203 })?;
204
205 tmp_file.sync_all().map_err(|e| StoreError::IoError {
206 operation: IoOperationKind::Sync,
207 path: tmp_path.display().to_string(),
208 context: Some("temporary file".to_string()),
209 error: e.to_string(),
210 })?;
211
212 drop(tmp_file);
213
214 atomic_io::atomic_rename(
215 &tmp_path,
216 &self.path,
217 self.strategy.atomic_write.retry_count,
218 )?;
219
220 if self.strategy.atomic_write.cleanup_tmp_files {
221 let _ = atomic_io::cleanup_temp_files(&self.path);
222 }
223
224 Ok(())
225 }
226
227 pub fn path(&self) -> &Path {
229 &self.path
230 }
231
232 pub fn strategy(&self) -> &FileStorageStrategy {
234 &self.strategy
235 }
236
237 fn default_value_as_string(&self) -> Result<String, StoreError> {
243 let json_value = self
244 .strategy
245 .default_value
246 .clone()
247 .unwrap_or(JsonValue::Object(Default::default()));
248
249 match self.strategy.format {
250 FormatStrategy::Json => {
251 serde_json::to_string_pretty(&json_value).map_err(|e| StoreError::IoError {
252 operation: IoOperationKind::Write,
253 path: self.path.display().to_string(),
254 context: Some("serialize default value".to_string()),
255 error: e.to_string(),
256 })
257 }
258 FormatStrategy::Toml => {
259 let toml_value = json_to_toml(&json_value)?;
260 toml::to_string_pretty(&toml_value).map_err(|e| StoreError::IoError {
261 operation: IoOperationKind::Write,
262 path: self.path.display().to_string(),
263 context: Some("serialize default value as toml".to_string()),
264 error: e.to_string(),
265 })
266 }
267 }
268 }
269}
270
271#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::fs;
279 use tempfile::TempDir;
280
281 #[test]
286 fn test_new_creates_file_with_save_if_missing() {
287 let dir = TempDir::new().unwrap();
288 let path = dir.path().join("config.toml");
289
290 let strategy = FileStorageStrategy::new()
291 .with_load_behavior(LoadBehavior::SaveIfMissing)
292 .with_default_value(serde_json::json!({"key": "value"}));
293
294 assert!(!path.exists());
295 let storage = FileStorage::new(path.clone(), strategy).unwrap();
296 assert!(path.exists(), "file must be created for SaveIfMissing");
297 assert_eq!(storage.path(), path.as_path());
298 }
299
300 #[test]
301 fn test_new_no_file_create_if_missing() {
302 let dir = TempDir::new().unwrap();
303 let path = dir.path().join("missing.toml");
304
305 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
306
307 let storage = FileStorage::new(path.clone(), strategy).unwrap();
308 assert!(!path.exists());
310 assert_eq!(storage.path(), path.as_path());
311 }
312
313 #[test]
314 fn test_new_error_if_missing() {
315 let dir = TempDir::new().unwrap();
316 let path = dir.path().join("absent.toml");
317
318 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
319
320 let result = FileStorage::new(path, strategy);
321 assert!(result.is_err());
322 assert!(matches!(
323 result,
324 Err(StoreError::IoError {
325 operation: IoOperationKind::Read,
326 ..
327 })
328 ));
329 }
330
331 #[test]
336 fn test_read_string_returns_file_content() {
337 let dir = TempDir::new().unwrap();
338 let path = dir.path().join("data.json");
339 fs::write(&path, r#"{"hello":"world"}"#).unwrap();
340
341 let strategy = FileStorageStrategy::new()
342 .with_format(FormatStrategy::Json)
343 .with_load_behavior(LoadBehavior::ErrorIfMissing);
344
345 let storage = FileStorage::new(path, strategy).unwrap();
346 let content = storage.read_string().unwrap();
347 assert_eq!(content, r#"{"hello":"world"}"#);
348 }
349
350 #[test]
351 fn test_write_string_creates_and_reads_back() {
352 let dir = TempDir::new().unwrap();
353 let path = dir.path().join("out.json");
354
355 let strategy = FileStorageStrategy::new()
356 .with_format(FormatStrategy::Json)
357 .with_load_behavior(LoadBehavior::CreateIfMissing);
358
359 let storage = FileStorage::new(path.clone(), strategy).unwrap();
360 storage.write_string(r#"{"x":1}"#).unwrap();
361
362 let back = storage.read_string().unwrap();
363 assert_eq!(back, r#"{"x":1}"#);
364 }
365
366 #[test]
367 fn test_write_string_creates_parent_dirs() {
368 let dir = TempDir::new().unwrap();
369 let path = dir.path().join("a").join("b").join("c.toml");
370
371 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
372
373 let storage = FileStorage::new(path.clone(), strategy).unwrap();
374 storage.write_string("").unwrap();
375 assert!(path.exists());
376 }
377
378 #[test]
383 fn test_atomic_write_no_tmp_left() {
384 let dir = TempDir::new().unwrap();
385 let path = dir.path().join("atomic.toml");
386 let strategy = FileStorageStrategy::default();
387
388 let storage = FileStorage::new(path.clone(), strategy).unwrap();
389 storage.write_string("hello = true\n").unwrap();
390
391 let tmp_files: Vec<_> = fs::read_dir(dir.path())
392 .unwrap()
393 .filter_map(|e| e.ok())
394 .filter(|e| {
395 e.file_name()
396 .to_string_lossy()
397 .starts_with(".atomic.toml.tmp")
398 })
399 .collect();
400 assert_eq!(tmp_files.len(), 0, "no temp files should remain");
401 }
402
403 #[test]
408 fn test_cleanup_removes_stale_tmp_files() {
409 let dir = TempDir::new().unwrap();
410 let path = dir.path().join("cfg.toml");
411
412 let fake1 = dir.path().join(".cfg.toml.tmp.11111");
413 let fake2 = dir.path().join(".cfg.toml.tmp.22222");
414 fs::write(&fake1, "stale1").unwrap();
415 fs::write(&fake2, "stale2").unwrap();
416
417 let strategy = FileStorageStrategy::default();
418 let storage = FileStorage::new(path.clone(), strategy).unwrap();
419 storage.write_string("cfg = true\n").unwrap();
420
421 assert!(!fake1.exists(), "stale tmp 1 should be removed");
422 assert!(!fake2.exists(), "stale tmp 2 should be removed");
423 }
424
425 #[test]
426 fn test_no_cleanup_keeps_stale_tmp_files() {
427 let dir = TempDir::new().unwrap();
428 let path = dir.path().join("no_clean.toml");
429 let fake = dir.path().join(".no_clean.toml.tmp.99999");
430 fs::write(&fake, "stale").unwrap();
431
432 let strategy = FileStorageStrategy::new().with_cleanup(false);
433 let storage = FileStorage::new(path.clone(), strategy).unwrap();
434 storage.write_string("x = 1\n").unwrap();
435
436 assert!(fake.exists(), "stale tmp must remain when cleanup=false");
437 }
438
439 #[test]
444 fn test_save_if_missing_json_format() {
445 let dir = TempDir::new().unwrap();
446 let path = dir.path().join("data.json");
447
448 let strategy = FileStorageStrategy::new()
449 .with_format(FormatStrategy::Json)
450 .with_load_behavior(LoadBehavior::SaveIfMissing)
451 .with_default_value(serde_json::json!({"items": []}));
452
453 FileStorage::new(path.clone(), strategy).unwrap();
454 let content = fs::read_to_string(&path).unwrap();
455 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
457 assert!(parsed.get("items").is_some());
458 }
459
460 #[test]
461 fn test_save_if_missing_toml_format() {
462 let dir = TempDir::new().unwrap();
463 let path = dir.path().join("data.toml");
464
465 let strategy = FileStorageStrategy::new()
466 .with_format(FormatStrategy::Toml)
467 .with_load_behavior(LoadBehavior::SaveIfMissing)
468 .with_default_value(serde_json::json!({"name": "alice"}));
469
470 FileStorage::new(path.clone(), strategy).unwrap();
471 let content = fs::read_to_string(&path).unwrap();
472 let parsed: toml::Value = toml::from_str(&content).unwrap();
474 assert!(parsed.get("name").is_some());
475 }
476
477 #[test]
482 fn test_strategy_builder() {
483 let s = FileStorageStrategy::new()
484 .with_format(FormatStrategy::Json)
485 .with_retry_count(5)
486 .with_cleanup(false)
487 .with_load_behavior(LoadBehavior::ErrorIfMissing);
488
489 assert_eq!(s.format, FormatStrategy::Json);
490 assert_eq!(s.atomic_write.retry_count, 5);
491 assert!(!s.atomic_write.cleanup_tmp_files);
492 assert_eq!(s.load_behavior, LoadBehavior::ErrorIfMissing);
493 }
494
495 #[test]
496 fn test_atomic_write_config_default() {
497 let cfg = AtomicWriteConfig::default();
498 assert_eq!(cfg.retry_count, 3);
499 assert!(cfg.cleanup_tmp_files);
500 }
501
502 #[test]
503 fn test_format_strategy_equality() {
504 assert_eq!(FormatStrategy::Toml, FormatStrategy::Toml);
505 assert_ne!(FormatStrategy::Toml, FormatStrategy::Json);
506 }
507
508 #[test]
509 fn test_load_behavior_equality() {
510 assert_eq!(LoadBehavior::CreateIfMissing, LoadBehavior::CreateIfMissing);
511 assert_ne!(LoadBehavior::CreateIfMissing, LoadBehavior::ErrorIfMissing);
512 }
513}