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