1use crate::{errors::IoOperationKind, ConfigMigrator, MigrationError, Migrator, Queryable};
6use serde_json::Value as JsonValue;
7use std::fs::{self, File, OpenOptions};
8use std::io::Write as IoWrite;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FormatStrategy {
14 Toml,
16 Json,
18}
19
20#[derive(Debug, Clone)]
22pub struct AtomicWriteConfig {
23 pub retry_count: usize,
25 pub cleanup_tmp_files: bool,
27}
28
29impl Default for AtomicWriteConfig {
30 fn default() -> Self {
31 Self {
32 retry_count: 3,
33 cleanup_tmp_files: true,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LoadBehavior {
41 CreateIfMissing,
43 ErrorIfMissing,
45}
46
47#[derive(Debug, Clone)]
49pub struct FileStorageStrategy {
50 pub format: FormatStrategy,
52 pub atomic_write: AtomicWriteConfig,
54 pub load_behavior: LoadBehavior,
56}
57
58impl Default for FileStorageStrategy {
59 fn default() -> Self {
60 Self {
61 format: FormatStrategy::Toml,
62 atomic_write: AtomicWriteConfig::default(),
63 load_behavior: LoadBehavior::CreateIfMissing,
64 }
65 }
66}
67
68impl FileStorageStrategy {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn with_format(mut self, format: FormatStrategy) -> Self {
76 self.format = format;
77 self
78 }
79
80 pub fn with_retry_count(mut self, count: usize) -> Self {
82 self.atomic_write.retry_count = count;
83 self
84 }
85
86 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
88 self.atomic_write.cleanup_tmp_files = cleanup;
89 self
90 }
91
92 pub fn with_load_behavior(mut self, behavior: LoadBehavior) -> Self {
94 self.load_behavior = behavior;
95 self
96 }
97}
98
99pub struct FileStorage {
107 path: PathBuf,
108 config: ConfigMigrator,
109 strategy: FileStorageStrategy,
110}
111
112impl FileStorage {
113 pub fn new(
141 path: PathBuf,
142 migrator: Migrator,
143 strategy: FileStorageStrategy,
144 ) -> Result<Self, MigrationError> {
145 let json_string = if path.exists() {
147 let content = fs::read_to_string(&path).map_err(|e| MigrationError::IoError {
148 operation: IoOperationKind::Read,
149 path: path.display().to_string(),
150 context: None,
151 error: e.to_string(),
152 })?;
153
154 if content.trim().is_empty() {
155 "{}".to_string()
157 } else {
158 match strategy.format {
160 FormatStrategy::Toml => {
161 let toml_value: toml::Value = toml::from_str(&content)
162 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
163 let json_value = toml_to_json(toml_value)?;
164 serde_json::to_string(&json_value)
165 .map_err(|e| MigrationError::SerializationError(e.to_string()))?
166 }
167 FormatStrategy::Json => content,
168 }
169 }
170 } else {
171 match strategy.load_behavior {
173 LoadBehavior::CreateIfMissing => "{}".to_string(),
174 LoadBehavior::ErrorIfMissing => {
175 return Err(MigrationError::IoError {
176 operation: IoOperationKind::Read,
177 path: path.display().to_string(),
178 context: None,
179 error: "File not found".to_string(),
180 });
181 }
182 }
183 };
184
185 let config = ConfigMigrator::from(&json_string, migrator)?;
187
188 Ok(Self {
189 path,
190 config,
191 strategy,
192 })
193 }
194
195 pub fn save(&self) -> Result<(), MigrationError> {
200 if let Some(parent) = self.path.parent() {
202 if !parent.exists() {
203 fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
204 operation: IoOperationKind::CreateDir,
205 path: parent.display().to_string(),
206 context: Some("parent directory".to_string()),
207 error: e.to_string(),
208 })?;
209 }
210 }
211
212 let json_value = self.config.as_value();
214
215 let content = match self.strategy.format {
217 FormatStrategy::Toml => {
218 let toml_value = json_to_toml(json_value)?;
219 toml::to_string_pretty(&toml_value)
220 .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))?
221 }
222 FormatStrategy::Json => serde_json::to_string_pretty(&json_value)
223 .map_err(|e| MigrationError::SerializationError(e.to_string()))?,
224 };
225
226 let tmp_path = self.get_temp_path()?;
228 let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
229 operation: IoOperationKind::Create,
230 path: tmp_path.display().to_string(),
231 context: Some("temporary file".to_string()),
232 error: e.to_string(),
233 })?;
234
235 tmp_file
236 .write_all(content.as_bytes())
237 .map_err(|e| MigrationError::IoError {
238 operation: IoOperationKind::Write,
239 path: tmp_path.display().to_string(),
240 context: Some("temporary file".to_string()),
241 error: e.to_string(),
242 })?;
243
244 tmp_file.sync_all().map_err(|e| MigrationError::IoError {
246 operation: IoOperationKind::Sync,
247 path: tmp_path.display().to_string(),
248 context: Some("temporary file".to_string()),
249 error: e.to_string(),
250 })?;
251
252 drop(tmp_file);
253
254 self.atomic_rename(&tmp_path)?;
256
257 if self.strategy.atomic_write.cleanup_tmp_files {
259 let _ = self.cleanup_temp_files();
260 }
261
262 Ok(())
263 }
264
265 pub fn config(&self) -> &ConfigMigrator {
267 &self.config
268 }
269
270 pub fn config_mut(&mut self) -> &mut ConfigMigrator {
272 &mut self.config
273 }
274
275 pub fn query<T>(&self, key: &str) -> Result<Vec<T>, MigrationError>
279 where
280 T: Queryable + for<'de> serde::Deserialize<'de>,
281 {
282 self.config.query(key)
283 }
284
285 pub fn update<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
289 where
290 T: Queryable + serde::Serialize,
291 {
292 self.config.update(key, value)
293 }
294
295 pub fn update_and_save<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
297 where
298 T: Queryable + serde::Serialize,
299 {
300 self.update(key, value)?;
301 self.save()
302 }
303
304 pub fn path(&self) -> &Path {
310 &self.path
311 }
312
313 fn get_temp_path(&self) -> Result<PathBuf, MigrationError> {
315 let parent = self.path.parent().ok_or_else(|| {
316 MigrationError::PathResolution("Path has no parent directory".to_string())
317 })?;
318
319 let file_name = self
320 .path
321 .file_name()
322 .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
323
324 let tmp_name = format!(
325 ".{}.tmp.{}",
326 file_name.to_string_lossy(),
327 std::process::id()
328 );
329 Ok(parent.join(tmp_name))
330 }
331
332 fn atomic_rename(&self, tmp_path: &Path) -> Result<(), MigrationError> {
334 let mut last_error = None;
335
336 for attempt in 0..self.strategy.atomic_write.retry_count {
337 match fs::rename(tmp_path, &self.path) {
338 Ok(()) => return Ok(()),
339 Err(e) => {
340 last_error = Some(e);
341 if attempt + 1 < self.strategy.atomic_write.retry_count {
342 std::thread::sleep(std::time::Duration::from_millis(10));
344 }
345 }
346 }
347 }
348
349 Err(MigrationError::IoError {
350 operation: IoOperationKind::Rename,
351 path: self.path.display().to_string(),
352 context: Some(format!(
353 "after {} retries",
354 self.strategy.atomic_write.retry_count
355 )),
356 error: last_error.unwrap().to_string(),
357 })
358 }
359
360 fn cleanup_temp_files(&self) -> std::io::Result<()> {
362 let parent = match self.path.parent() {
363 Some(p) => p,
364 None => return Ok(()),
365 };
366
367 let file_name = match self.path.file_name() {
368 Some(f) => f.to_string_lossy(),
369 None => return Ok(()),
370 };
371
372 let prefix = format!(".{}.tmp.", file_name);
373
374 if let Ok(entries) = fs::read_dir(parent) {
375 for entry in entries.flatten() {
376 if let Ok(name) = entry.file_name().into_string() {
377 if name.starts_with(&prefix) {
378 let _ = fs::remove_file(entry.path());
380 }
381 }
382 }
383 }
384
385 Ok(())
386 }
387}
388
389#[allow(dead_code)]
393struct FileLock {
394 file: File,
395 lock_path: PathBuf,
396}
397
398#[allow(dead_code)]
399impl FileLock {
400 fn acquire(path: &Path) -> Result<Self, MigrationError> {
402 let lock_path = path.with_extension("lock");
404
405 if let Some(parent) = lock_path.parent() {
407 if !parent.exists() {
408 fs::create_dir_all(parent).map_err(|e| MigrationError::LockError {
409 path: lock_path.display().to_string(),
410 error: e.to_string(),
411 })?;
412 }
413 }
414
415 let file = OpenOptions::new()
417 .write(true)
418 .create(true)
419 .truncate(false)
420 .open(&lock_path)
421 .map_err(|e| MigrationError::LockError {
422 path: lock_path.display().to_string(),
423 error: e.to_string(),
424 })?;
425
426 #[cfg(unix)]
428 {
429 use fs2::FileExt;
430 file.lock_exclusive()
431 .map_err(|e| MigrationError::LockError {
432 path: lock_path.display().to_string(),
433 error: format!("Failed to acquire exclusive lock: {}", e),
434 })?;
435 }
436
437 #[cfg(not(unix))]
438 {
439 }
443
444 Ok(FileLock { file, lock_path })
445 }
446}
447
448impl Drop for FileLock {
449 fn drop(&mut self) {
450 let _ = fs::remove_file(&self.lock_path);
453 }
454}
455
456fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
458 let json_str = serde_json::to_string(&toml_value)
460 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
461 let json_value: JsonValue = serde_json::from_str(&json_str)
462 .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
463 Ok(json_value)
464}
465
466fn json_to_toml(json_value: &JsonValue) -> Result<toml::Value, MigrationError> {
468 let json_str = serde_json::to_string(json_value)
470 .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
471 let toml_value: toml::Value = serde_json::from_str(&json_str)
472 .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
473 Ok(toml_value)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::{IntoDomain, MigratesTo, Versioned};
480 use serde::{Deserialize, Serialize};
481 use tempfile::TempDir;
482
483 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484 struct TestEntity {
485 name: String,
486 count: u32,
487 }
488
489 impl Queryable for TestEntity {
490 const ENTITY_NAME: &'static str = "test";
491 }
492
493 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494 struct TestV1 {
495 name: String,
496 }
497
498 impl Versioned for TestV1 {
499 const VERSION: &'static str = "1.0.0";
500 }
501
502 impl MigratesTo<TestV2> for TestV1 {
503 fn migrate(self) -> TestV2 {
504 TestV2 {
505 name: self.name,
506 count: 0,
507 }
508 }
509 }
510
511 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512 struct TestV2 {
513 name: String,
514 count: u32,
515 }
516
517 impl Versioned for TestV2 {
518 const VERSION: &'static str = "2.0.0";
519 }
520
521 impl IntoDomain<TestEntity> for TestV2 {
522 fn into_domain(self) -> TestEntity {
523 TestEntity {
524 name: self.name,
525 count: self.count,
526 }
527 }
528 }
529
530 fn setup_migrator() -> Migrator {
531 let path = Migrator::define("test")
532 .from::<TestV1>()
533 .step::<TestV2>()
534 .into::<TestEntity>();
535
536 let mut migrator = Migrator::new();
537 migrator.register(path).unwrap();
538 migrator
539 }
540
541 #[test]
542 fn test_file_storage_strategy_builder() {
543 let strategy = FileStorageStrategy::new()
544 .with_format(FormatStrategy::Json)
545 .with_retry_count(5)
546 .with_cleanup(false)
547 .with_load_behavior(LoadBehavior::ErrorIfMissing);
548
549 assert_eq!(strategy.format, FormatStrategy::Json);
550 assert_eq!(strategy.atomic_write.retry_count, 5);
551 assert!(!strategy.atomic_write.cleanup_tmp_files);
552 assert_eq!(strategy.load_behavior, LoadBehavior::ErrorIfMissing);
553 }
554
555 #[test]
556 fn test_save_and_load_toml() {
557 let temp_dir = TempDir::new().unwrap();
558 let file_path = temp_dir.path().join("test.toml");
559 let migrator = setup_migrator();
560 let strategy = FileStorageStrategy::default(); let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
563
564 let entities = vec![TestEntity {
566 name: "test".to_string(),
567 count: 42,
568 }];
569 storage.update_and_save("test", entities).unwrap();
570
571 let migrator2 = setup_migrator();
573 let storage2 =
574 FileStorage::new(file_path, migrator2, FileStorageStrategy::default()).unwrap();
575
576 let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
578 assert_eq!(loaded.len(), 1);
579 assert_eq!(loaded[0].name, "test");
580 assert_eq!(loaded[0].count, 42);
581 }
582
583 #[test]
584 fn test_save_and_load_json() {
585 let temp_dir = TempDir::new().unwrap();
586 let file_path = temp_dir.path().join("test.json");
587 let migrator = setup_migrator();
588 let strategy = FileStorageStrategy::new().with_format(FormatStrategy::Json);
589
590 let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
591
592 let entities = vec![TestEntity {
594 name: "json_test".to_string(),
595 count: 100,
596 }];
597 storage.update_and_save("test", entities).unwrap();
598
599 let migrator2 = setup_migrator();
601 let strategy2 = FileStorageStrategy::new().with_format(FormatStrategy::Json);
602 let storage2 = FileStorage::new(file_path, migrator2, strategy2).unwrap();
603
604 let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
606 assert_eq!(loaded.len(), 1);
607 assert_eq!(loaded[0].name, "json_test");
608 assert_eq!(loaded[0].count, 100);
609 }
610
611 #[test]
612 fn test_load_behavior_create_if_missing() {
613 let temp_dir = TempDir::new().unwrap();
614 let file_path = temp_dir.path().join("nonexistent.toml");
615 let migrator = setup_migrator();
616 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
617
618 let result = FileStorage::new(file_path, migrator, strategy);
619
620 assert!(result.is_ok()); }
622
623 #[test]
624 fn test_load_behavior_error_if_missing() {
625 let temp_dir = TempDir::new().unwrap();
626 let file_path = temp_dir.path().join("nonexistent.toml");
627 let migrator = setup_migrator();
628 let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
629
630 let result = FileStorage::new(file_path, migrator, strategy);
631
632 assert!(result.is_err()); assert!(matches!(result, Err(MigrationError::IoError { .. })));
634 }
635
636 #[test]
637 fn test_atomic_write_no_tmp_file_left() {
638 let temp_dir = TempDir::new().unwrap();
639 let file_path = temp_dir.path().join("atomic.toml");
640 let migrator = setup_migrator();
641 let strategy = FileStorageStrategy::default();
642
643 let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
644
645 let entities = vec![TestEntity {
646 name: "atomic".to_string(),
647 count: 1,
648 }];
649 storage.update_and_save("test", entities).unwrap();
650
651 let entries: Vec<_> = fs::read_dir(temp_dir.path())
653 .unwrap()
654 .filter_map(|e| e.ok())
655 .collect();
656
657 let tmp_files: Vec<_> = entries
658 .iter()
659 .filter(|e| {
660 e.file_name()
661 .to_string_lossy()
662 .starts_with(".atomic.toml.tmp")
663 })
664 .collect();
665
666 assert_eq!(tmp_files.len(), 0, "Temporary files should be cleaned up");
667 }
668
669 #[test]
670 fn test_file_storage_path() {
671 let temp_dir = TempDir::new().unwrap();
672 let file_path = temp_dir.path().join("test_config.toml");
673 let migrator = setup_migrator();
674 let strategy = FileStorageStrategy::default();
675
676 let storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
677
678 let returned_path = storage.path();
680 assert_eq!(returned_path, file_path.as_path());
681 }
682}