version_migrate/
storage.rs

1//! File storage layer with ACID guarantees for versioned configuration.
2//!
3//! Provides atomic file operations, format conversion, and file locking.
4
5use 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/// File format strategy for storage operations.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FormatStrategy {
14    /// TOML format (recommended for human-editable configs)
15    Toml,
16    /// JSON format
17    Json,
18}
19
20/// Configuration for atomic write operations.
21#[derive(Debug, Clone)]
22pub struct AtomicWriteConfig {
23    /// Number of times to retry rename operation (default: 3)
24    pub retry_count: usize,
25    /// Whether to clean up old temporary files (best effort)
26    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/// Behavior when loading a file that doesn't exist.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LoadBehavior {
41    /// Create an empty ConfigMigrator if file is missing
42    CreateIfMissing,
43    /// Create an empty ConfigMigrator and save it to file if missing
44    SaveIfMissing,
45    /// Return an error if file is missing
46    ErrorIfMissing,
47}
48
49/// Strategy for file storage operations.
50#[derive(Debug, Clone)]
51pub struct FileStorageStrategy {
52    /// File format to use
53    pub format: FormatStrategy,
54    /// Atomic write configuration
55    pub atomic_write: AtomicWriteConfig,
56    /// Behavior when file doesn't exist
57    pub load_behavior: LoadBehavior,
58}
59
60impl Default for FileStorageStrategy {
61    fn default() -> Self {
62        Self {
63            format: FormatStrategy::Toml,
64            atomic_write: AtomicWriteConfig::default(),
65            load_behavior: LoadBehavior::CreateIfMissing,
66        }
67    }
68}
69
70impl FileStorageStrategy {
71    /// Create a new strategy with default values.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Set the file format.
77    pub fn with_format(mut self, format: FormatStrategy) -> Self {
78        self.format = format;
79        self
80    }
81
82    /// Set the retry count for atomic writes.
83    pub fn with_retry_count(mut self, count: usize) -> Self {
84        self.atomic_write.retry_count = count;
85        self
86    }
87
88    /// Set whether to cleanup temporary files.
89    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
90        self.atomic_write.cleanup_tmp_files = cleanup;
91        self
92    }
93
94    /// Set the load behavior.
95    pub fn with_load_behavior(mut self, behavior: LoadBehavior) -> Self {
96        self.load_behavior = behavior;
97        self
98    }
99}
100
101/// File storage with ACID guarantees and automatic migrations.
102///
103/// Provides:
104/// - **Atomicity**: Updates are all-or-nothing via tmp file + atomic rename
105/// - **Consistency**: Format validation on load/save
106/// - **Isolation**: File locking prevents concurrent modifications
107/// - **Durability**: Explicit fsync before rename
108pub struct FileStorage {
109    path: PathBuf,
110    config: ConfigMigrator,
111    strategy: FileStorageStrategy,
112}
113
114impl FileStorage {
115    /// Create a new FileStorage instance and load data from file.
116    ///
117    /// This combines initialization and loading into a single operation.
118    ///
119    /// # Arguments
120    ///
121    /// * `path` - Path to the storage file
122    /// * `migrator` - Migrator instance with registered migration paths
123    /// * `strategy` - Storage strategy configuration
124    ///
125    /// # Behavior
126    ///
127    /// Depends on `strategy.load_behavior`:
128    /// - `CreateIfMissing`: Creates empty config if file doesn't exist
129    /// - `ErrorIfMissing`: Returns error if file doesn't exist
130    ///
131    /// # Example
132    ///
133    /// ```ignore
134    /// let strategy = FileStorageStrategy::default();
135    /// let migrator = Migrator::new();
136    /// let storage = FileStorage::new(
137    ///     PathBuf::from("config.toml"),
138    ///     migrator,
139    ///     strategy
140    /// )?;
141    /// ```
142    pub fn new(
143        path: PathBuf,
144        migrator: Migrator,
145        strategy: FileStorageStrategy,
146    ) -> Result<Self, MigrationError> {
147        // Track if file was missing for SaveIfMissing behavior
148        let file_was_missing = !path.exists();
149
150        // Load file content if it exists
151        let json_string = if path.exists() {
152            let content = fs::read_to_string(&path).map_err(|e| MigrationError::IoError {
153                operation: IoOperationKind::Read,
154                path: path.display().to_string(),
155                context: None,
156                error: e.to_string(),
157            })?;
158
159            if content.trim().is_empty() {
160                // Empty file, use empty JSON
161                "{}".to_string()
162            } else {
163                // Parse based on format strategy
164                match strategy.format {
165                    FormatStrategy::Toml => {
166                        let toml_value: toml::Value = toml::from_str(&content)
167                            .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
168                        let json_value = toml_to_json(toml_value)?;
169                        serde_json::to_string(&json_value)
170                            .map_err(|e| MigrationError::SerializationError(e.to_string()))?
171                    }
172                    FormatStrategy::Json => content,
173                }
174            }
175        } else {
176            // File doesn't exist
177            match strategy.load_behavior {
178                LoadBehavior::CreateIfMissing | LoadBehavior::SaveIfMissing => "{}".to_string(),
179                LoadBehavior::ErrorIfMissing => {
180                    return Err(MigrationError::IoError {
181                        operation: IoOperationKind::Read,
182                        path: path.display().to_string(),
183                        context: None,
184                        error: "File not found".to_string(),
185                    });
186                }
187            }
188        };
189
190        // Create ConfigMigrator with loaded/empty data
191        let config = ConfigMigrator::from(&json_string, migrator)?;
192
193        let storage = Self {
194            path,
195            config,
196            strategy,
197        };
198
199        // If file was missing and SaveIfMissing is set, save the empty config
200        if file_was_missing && storage.strategy.load_behavior == LoadBehavior::SaveIfMissing {
201            storage.save()?;
202        }
203
204        Ok(storage)
205    }
206
207    /// Save current state to file atomically.
208    ///
209    /// Uses a temporary file + atomic rename to ensure durability.
210    /// Retries according to `strategy.atomic_write.retry_count`.
211    pub fn save(&self) -> Result<(), MigrationError> {
212        // Ensure parent directory exists
213        if let Some(parent) = self.path.parent() {
214            if !parent.exists() {
215                fs::create_dir_all(parent).map_err(|e| MigrationError::IoError {
216                    operation: IoOperationKind::CreateDir,
217                    path: parent.display().to_string(),
218                    context: Some("parent directory".to_string()),
219                    error: e.to_string(),
220                })?;
221            }
222        }
223
224        // Get current state as JSON
225        let json_value = self.config.as_value();
226
227        // Convert to target format
228        let content = match self.strategy.format {
229            FormatStrategy::Toml => {
230                let toml_value = json_to_toml(json_value)?;
231                toml::to_string_pretty(&toml_value)
232                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))?
233            }
234            FormatStrategy::Json => serde_json::to_string_pretty(&json_value)
235                .map_err(|e| MigrationError::SerializationError(e.to_string()))?,
236        };
237
238        // Write to temporary file
239        let tmp_path = self.get_temp_path()?;
240        let mut tmp_file = File::create(&tmp_path).map_err(|e| MigrationError::IoError {
241            operation: IoOperationKind::Create,
242            path: tmp_path.display().to_string(),
243            context: Some("temporary file".to_string()),
244            error: e.to_string(),
245        })?;
246
247        tmp_file
248            .write_all(content.as_bytes())
249            .map_err(|e| MigrationError::IoError {
250                operation: IoOperationKind::Write,
251                path: tmp_path.display().to_string(),
252                context: Some("temporary file".to_string()),
253                error: e.to_string(),
254            })?;
255
256        // Ensure data is written to disk
257        tmp_file.sync_all().map_err(|e| MigrationError::IoError {
258            operation: IoOperationKind::Sync,
259            path: tmp_path.display().to_string(),
260            context: Some("temporary file".to_string()),
261            error: e.to_string(),
262        })?;
263
264        drop(tmp_file);
265
266        // Atomic rename with retry
267        self.atomic_rename(&tmp_path)?;
268
269        // Cleanup old temp files (best effort)
270        if self.strategy.atomic_write.cleanup_tmp_files {
271            let _ = self.cleanup_temp_files();
272        }
273
274        Ok(())
275    }
276
277    /// Get immutable reference to the ConfigMigrator.
278    pub fn config(&self) -> &ConfigMigrator {
279        &self.config
280    }
281
282    /// Get mutable reference to the ConfigMigrator.
283    pub fn config_mut(&mut self) -> &mut ConfigMigrator {
284        &mut self.config
285    }
286
287    /// Query entities from storage.
288    ///
289    /// Delegates to `ConfigMigrator::query()`.
290    pub fn query<T>(&self, key: &str) -> Result<Vec<T>, MigrationError>
291    where
292        T: Queryable + for<'de> serde::Deserialize<'de>,
293    {
294        self.config.query(key)
295    }
296
297    /// Update entities in memory (does not save to file).
298    ///
299    /// Delegates to `ConfigMigrator::update()`.
300    pub fn update<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
301    where
302        T: Queryable + serde::Serialize,
303    {
304        self.config.update(key, value)
305    }
306
307    /// Update entities and immediately save to file atomically.
308    pub fn update_and_save<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
309    where
310        T: Queryable + serde::Serialize,
311    {
312        self.update(key, value)?;
313        self.save()
314    }
315
316    /// Returns a reference to the storage file path.
317    ///
318    /// # Returns
319    ///
320    /// A reference to the file path where the configuration is stored.
321    pub fn path(&self) -> &Path {
322        &self.path
323    }
324
325    /// Get path to temporary file for atomic writes.
326    fn get_temp_path(&self) -> Result<PathBuf, MigrationError> {
327        let parent = self.path.parent().ok_or_else(|| {
328            MigrationError::PathResolution("Path has no parent directory".to_string())
329        })?;
330
331        let file_name = self
332            .path
333            .file_name()
334            .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
335
336        let tmp_name = format!(
337            ".{}.tmp.{}",
338            file_name.to_string_lossy(),
339            std::process::id()
340        );
341        Ok(parent.join(tmp_name))
342    }
343
344    /// Atomically rename temporary file to target path with retry.
345    fn atomic_rename(&self, tmp_path: &Path) -> Result<(), MigrationError> {
346        let mut last_error = None;
347
348        for attempt in 0..self.strategy.atomic_write.retry_count {
349            match fs::rename(tmp_path, &self.path) {
350                Ok(()) => return Ok(()),
351                Err(e) => {
352                    last_error = Some(e);
353                    if attempt + 1 < self.strategy.atomic_write.retry_count {
354                        // Small delay before retry
355                        std::thread::sleep(std::time::Duration::from_millis(10));
356                    }
357                }
358            }
359        }
360
361        Err(MigrationError::IoError {
362            operation: IoOperationKind::Rename,
363            path: self.path.display().to_string(),
364            context: Some(format!(
365                "after {} retries",
366                self.strategy.atomic_write.retry_count
367            )),
368            error: last_error.unwrap().to_string(),
369        })
370    }
371
372    /// Clean up old temporary files (best effort).
373    fn cleanup_temp_files(&self) -> std::io::Result<()> {
374        let parent = match self.path.parent() {
375            Some(p) => p,
376            None => return Ok(()),
377        };
378
379        let file_name = match self.path.file_name() {
380            Some(f) => f.to_string_lossy(),
381            None => return Ok(()),
382        };
383
384        let prefix = format!(".{}.tmp.", file_name);
385
386        if let Ok(entries) = fs::read_dir(parent) {
387            for entry in entries.flatten() {
388                if let Ok(name) = entry.file_name().into_string() {
389                    if name.starts_with(&prefix) {
390                        // Try to remove, but ignore errors (best effort)
391                        let _ = fs::remove_file(entry.path());
392                    }
393                }
394            }
395        }
396
397        Ok(())
398    }
399}
400
401/// File lock guard that automatically releases the lock when dropped.
402///
403/// Currently unused, but reserved for future concurrent access features.
404#[allow(dead_code)]
405struct FileLock {
406    file: File,
407    lock_path: PathBuf,
408}
409
410#[allow(dead_code)]
411impl FileLock {
412    /// Acquire an exclusive lock on the given path.
413    fn acquire(path: &Path) -> Result<Self, MigrationError> {
414        // Create lock file path
415        let lock_path = path.with_extension("lock");
416
417        // Ensure parent directory exists
418        if let Some(parent) = lock_path.parent() {
419            if !parent.exists() {
420                fs::create_dir_all(parent).map_err(|e| MigrationError::LockError {
421                    path: lock_path.display().to_string(),
422                    error: e.to_string(),
423                })?;
424            }
425        }
426
427        // Open or create lock file
428        let file = OpenOptions::new()
429            .write(true)
430            .create(true)
431            .truncate(false)
432            .open(&lock_path)
433            .map_err(|e| MigrationError::LockError {
434                path: lock_path.display().to_string(),
435                error: e.to_string(),
436            })?;
437
438        // Try to acquire exclusive lock with fs2
439        #[cfg(unix)]
440        {
441            use fs2::FileExt;
442            file.lock_exclusive()
443                .map_err(|e| MigrationError::LockError {
444                    path: lock_path.display().to_string(),
445                    error: format!("Failed to acquire exclusive lock: {}", e),
446                })?;
447        }
448
449        #[cfg(not(unix))]
450        {
451            // On non-Unix systems, we don't have file locking
452            // This is acceptable for single-user desktop apps
453            // For production use on Windows, consider using advisory locking
454        }
455
456        Ok(FileLock { file, lock_path })
457    }
458}
459
460impl Drop for FileLock {
461    fn drop(&mut self) {
462        // Unlock is automatic when the file handle is dropped on Unix
463        // Try to remove lock file (best effort)
464        let _ = fs::remove_file(&self.lock_path);
465    }
466}
467
468/// Convert toml::Value to serde_json::Value.
469fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
470    // Serialize toml::Value to JSON string, then parse as serde_json::Value
471    let json_str = serde_json::to_string(&toml_value)
472        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
473    let json_value: JsonValue = serde_json::from_str(&json_str)
474        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
475    Ok(json_value)
476}
477
478/// Convert serde_json::Value to toml::Value.
479fn json_to_toml(json_value: &JsonValue) -> Result<toml::Value, MigrationError> {
480    // Serialize serde_json::Value to JSON string, then parse as toml::Value
481    let json_str = serde_json::to_string(json_value)
482        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
483    let toml_value: toml::Value = serde_json::from_str(&json_str)
484        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
485    Ok(toml_value)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::{IntoDomain, MigratesTo, Versioned};
492    use serde::{Deserialize, Serialize};
493    use tempfile::TempDir;
494
495    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496    struct TestEntity {
497        name: String,
498        count: u32,
499    }
500
501    impl Queryable for TestEntity {
502        const ENTITY_NAME: &'static str = "test";
503    }
504
505    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506    struct TestV1 {
507        name: String,
508    }
509
510    impl Versioned for TestV1 {
511        const VERSION: &'static str = "1.0.0";
512    }
513
514    impl MigratesTo<TestV2> for TestV1 {
515        fn migrate(self) -> TestV2 {
516            TestV2 {
517                name: self.name,
518                count: 0,
519            }
520        }
521    }
522
523    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524    struct TestV2 {
525        name: String,
526        count: u32,
527    }
528
529    impl Versioned for TestV2 {
530        const VERSION: &'static str = "2.0.0";
531    }
532
533    impl IntoDomain<TestEntity> for TestV2 {
534        fn into_domain(self) -> TestEntity {
535            TestEntity {
536                name: self.name,
537                count: self.count,
538            }
539        }
540    }
541
542    fn setup_migrator() -> Migrator {
543        let path = Migrator::define("test")
544            .from::<TestV1>()
545            .step::<TestV2>()
546            .into::<TestEntity>();
547
548        let mut migrator = Migrator::new();
549        migrator.register(path).unwrap();
550        migrator
551    }
552
553    #[test]
554    fn test_file_storage_strategy_builder() {
555        let strategy = FileStorageStrategy::new()
556            .with_format(FormatStrategy::Json)
557            .with_retry_count(5)
558            .with_cleanup(false)
559            .with_load_behavior(LoadBehavior::ErrorIfMissing);
560
561        assert_eq!(strategy.format, FormatStrategy::Json);
562        assert_eq!(strategy.atomic_write.retry_count, 5);
563        assert!(!strategy.atomic_write.cleanup_tmp_files);
564        assert_eq!(strategy.load_behavior, LoadBehavior::ErrorIfMissing);
565    }
566
567    #[test]
568    fn test_save_and_load_toml() {
569        let temp_dir = TempDir::new().unwrap();
570        let file_path = temp_dir.path().join("test.toml");
571        let migrator = setup_migrator();
572        let strategy = FileStorageStrategy::default(); // TOML by default
573
574        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
575
576        // Update and save
577        let entities = vec![TestEntity {
578            name: "test".to_string(),
579            count: 42,
580        }];
581        storage.update_and_save("test", entities).unwrap();
582
583        // Create new storage and load from saved file
584        let migrator2 = setup_migrator();
585        let storage2 =
586            FileStorage::new(file_path, migrator2, FileStorageStrategy::default()).unwrap();
587
588        // Query and verify
589        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
590        assert_eq!(loaded.len(), 1);
591        assert_eq!(loaded[0].name, "test");
592        assert_eq!(loaded[0].count, 42);
593    }
594
595    #[test]
596    fn test_save_and_load_json() {
597        let temp_dir = TempDir::new().unwrap();
598        let file_path = temp_dir.path().join("test.json");
599        let migrator = setup_migrator();
600        let strategy = FileStorageStrategy::new().with_format(FormatStrategy::Json);
601
602        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
603
604        // Update and save
605        let entities = vec![TestEntity {
606            name: "json_test".to_string(),
607            count: 100,
608        }];
609        storage.update_and_save("test", entities).unwrap();
610
611        // Create new storage and load from saved file
612        let migrator2 = setup_migrator();
613        let strategy2 = FileStorageStrategy::new().with_format(FormatStrategy::Json);
614        let storage2 = FileStorage::new(file_path, migrator2, strategy2).unwrap();
615
616        // Query and verify
617        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
618        assert_eq!(loaded.len(), 1);
619        assert_eq!(loaded[0].name, "json_test");
620        assert_eq!(loaded[0].count, 100);
621    }
622
623    #[test]
624    fn test_load_behavior_create_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::CreateIfMissing);
629
630        let result = FileStorage::new(file_path, migrator, strategy);
631
632        assert!(result.is_ok()); // Should not error when file doesn't exist
633    }
634
635    #[test]
636    fn test_load_behavior_error_if_missing() {
637        let temp_dir = TempDir::new().unwrap();
638        let file_path = temp_dir.path().join("nonexistent.toml");
639        let migrator = setup_migrator();
640        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
641
642        let result = FileStorage::new(file_path, migrator, strategy);
643
644        assert!(result.is_err()); // Should error when file doesn't exist
645        assert!(matches!(result, Err(MigrationError::IoError { .. })));
646    }
647
648    #[test]
649    fn test_load_behavior_save_if_missing() {
650        let temp_dir = TempDir::new().unwrap();
651        let file_path = temp_dir.path().join("save_if_missing.toml");
652        let migrator = setup_migrator();
653        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::SaveIfMissing);
654
655        // File should not exist initially
656        assert!(!file_path.exists());
657
658        let result = FileStorage::new(file_path.clone(), migrator, strategy.clone());
659
660        // Should succeed and create the file
661        assert!(result.is_ok());
662        assert!(file_path.exists());
663
664        // Verify we can read the file back
665        let _storage = result.unwrap();
666        let reloaded = FileStorage::new(file_path.clone(), setup_migrator(), strategy);
667        assert!(reloaded.is_ok());
668    }
669
670    #[test]
671    fn test_atomic_write_no_tmp_file_left() {
672        let temp_dir = TempDir::new().unwrap();
673        let file_path = temp_dir.path().join("atomic.toml");
674        let migrator = setup_migrator();
675        let strategy = FileStorageStrategy::default();
676
677        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
678
679        let entities = vec![TestEntity {
680            name: "atomic".to_string(),
681            count: 1,
682        }];
683        storage.update_and_save("test", entities).unwrap();
684
685        // Verify no temp file left behind
686        let entries: Vec<_> = fs::read_dir(temp_dir.path())
687            .unwrap()
688            .filter_map(|e| e.ok())
689            .collect();
690
691        let tmp_files: Vec<_> = entries
692            .iter()
693            .filter(|e| {
694                e.file_name()
695                    .to_string_lossy()
696                    .starts_with(".atomic.toml.tmp")
697            })
698            .collect();
699
700        assert_eq!(tmp_files.len(), 0, "Temporary files should be cleaned up");
701    }
702
703    #[test]
704    fn test_file_storage_path() {
705        let temp_dir = TempDir::new().unwrap();
706        let file_path = temp_dir.path().join("test_config.toml");
707        let migrator = setup_migrator();
708        let strategy = FileStorageStrategy::default();
709
710        let storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
711
712        // Verify path() returns the expected path
713        let returned_path = storage.path();
714        assert_eq!(returned_path, file_path.as_path());
715    }
716}