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    /// Get path to temporary file for atomic writes.
293    fn get_temp_path(&self) -> Result<PathBuf, MigrationError> {
294        let parent = self.path.parent().ok_or_else(|| {
295            MigrationError::PathResolution("Path has no parent directory".to_string())
296        })?;
297
298        let file_name = self
299            .path
300            .file_name()
301            .ok_or_else(|| MigrationError::PathResolution("Path has no file name".to_string()))?;
302
303        let tmp_name = format!(
304            ".{}.tmp.{}",
305            file_name.to_string_lossy(),
306            std::process::id()
307        );
308        Ok(parent.join(tmp_name))
309    }
310
311    /// Atomically rename temporary file to target path with retry.
312    fn atomic_rename(&self, tmp_path: &Path) -> Result<(), MigrationError> {
313        let mut last_error = None;
314
315        for attempt in 0..self.strategy.atomic_write.retry_count {
316            match fs::rename(tmp_path, &self.path) {
317                Ok(()) => return Ok(()),
318                Err(e) => {
319                    last_error = Some(e);
320                    if attempt + 1 < self.strategy.atomic_write.retry_count {
321                        // Small delay before retry
322                        std::thread::sleep(std::time::Duration::from_millis(10));
323                    }
324                }
325            }
326        }
327
328        Err(MigrationError::IoError {
329            path: self.path.display().to_string(),
330            error: format!(
331                "Failed to rename after {} attempts: {}",
332                self.strategy.atomic_write.retry_count,
333                last_error.unwrap()
334            ),
335        })
336    }
337
338    /// Clean up old temporary files (best effort).
339    fn cleanup_temp_files(&self) -> std::io::Result<()> {
340        let parent = match self.path.parent() {
341            Some(p) => p,
342            None => return Ok(()),
343        };
344
345        let file_name = match self.path.file_name() {
346            Some(f) => f.to_string_lossy(),
347            None => return Ok(()),
348        };
349
350        let prefix = format!(".{}.tmp.", file_name);
351
352        if let Ok(entries) = fs::read_dir(parent) {
353            for entry in entries.flatten() {
354                if let Ok(name) = entry.file_name().into_string() {
355                    if name.starts_with(&prefix) {
356                        // Try to remove, but ignore errors (best effort)
357                        let _ = fs::remove_file(entry.path());
358                    }
359                }
360            }
361        }
362
363        Ok(())
364    }
365}
366
367/// File lock guard that automatically releases the lock when dropped.
368///
369/// Currently unused, but reserved for future concurrent access features.
370#[allow(dead_code)]
371struct FileLock {
372    file: File,
373    lock_path: PathBuf,
374}
375
376#[allow(dead_code)]
377impl FileLock {
378    /// Acquire an exclusive lock on the given path.
379    fn acquire(path: &Path) -> Result<Self, MigrationError> {
380        // Create lock file path
381        let lock_path = path.with_extension("lock");
382
383        // Ensure parent directory exists
384        if let Some(parent) = lock_path.parent() {
385            if !parent.exists() {
386                fs::create_dir_all(parent).map_err(|e| MigrationError::LockError {
387                    path: lock_path.display().to_string(),
388                    error: e.to_string(),
389                })?;
390            }
391        }
392
393        // Open or create lock file
394        let file = OpenOptions::new()
395            .write(true)
396            .create(true)
397            .truncate(false)
398            .open(&lock_path)
399            .map_err(|e| MigrationError::LockError {
400                path: lock_path.display().to_string(),
401                error: e.to_string(),
402            })?;
403
404        // Try to acquire exclusive lock with fs2
405        #[cfg(unix)]
406        {
407            use fs2::FileExt;
408            file.lock_exclusive()
409                .map_err(|e| MigrationError::LockError {
410                    path: lock_path.display().to_string(),
411                    error: format!("Failed to acquire exclusive lock: {}", e),
412                })?;
413        }
414
415        #[cfg(not(unix))]
416        {
417            // On non-Unix systems, we don't have file locking
418            // This is acceptable for single-user desktop apps
419            // For production use on Windows, consider using advisory locking
420        }
421
422        Ok(FileLock { file, lock_path })
423    }
424}
425
426impl Drop for FileLock {
427    fn drop(&mut self) {
428        // Unlock is automatic when the file handle is dropped on Unix
429        // Try to remove lock file (best effort)
430        let _ = fs::remove_file(&self.lock_path);
431    }
432}
433
434/// Convert toml::Value to serde_json::Value.
435fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
436    // Serialize toml::Value to JSON string, then parse as serde_json::Value
437    let json_str = serde_json::to_string(&toml_value)
438        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
439    let json_value: JsonValue = serde_json::from_str(&json_str)
440        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
441    Ok(json_value)
442}
443
444/// Convert serde_json::Value to toml::Value.
445fn json_to_toml(json_value: &JsonValue) -> Result<toml::Value, MigrationError> {
446    // Serialize serde_json::Value to JSON string, then parse as toml::Value
447    let json_str = serde_json::to_string(json_value)
448        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
449    let toml_value: toml::Value = serde_json::from_str(&json_str)
450        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
451    Ok(toml_value)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::{IntoDomain, MigratesTo, Versioned};
458    use serde::{Deserialize, Serialize};
459    use tempfile::TempDir;
460
461    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462    struct TestEntity {
463        name: String,
464        count: u32,
465    }
466
467    impl Queryable for TestEntity {
468        const ENTITY_NAME: &'static str = "test";
469    }
470
471    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472    struct TestV1 {
473        name: String,
474    }
475
476    impl Versioned for TestV1 {
477        const VERSION: &'static str = "1.0.0";
478    }
479
480    impl MigratesTo<TestV2> for TestV1 {
481        fn migrate(self) -> TestV2 {
482            TestV2 {
483                name: self.name,
484                count: 0,
485            }
486        }
487    }
488
489    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490    struct TestV2 {
491        name: String,
492        count: u32,
493    }
494
495    impl Versioned for TestV2 {
496        const VERSION: &'static str = "2.0.0";
497    }
498
499    impl IntoDomain<TestEntity> for TestV2 {
500        fn into_domain(self) -> TestEntity {
501            TestEntity {
502                name: self.name,
503                count: self.count,
504            }
505        }
506    }
507
508    fn setup_migrator() -> Migrator {
509        let path = Migrator::define("test")
510            .from::<TestV1>()
511            .step::<TestV2>()
512            .into::<TestEntity>();
513
514        let mut migrator = Migrator::new();
515        migrator.register(path).unwrap();
516        migrator
517    }
518
519    #[test]
520    fn test_file_storage_strategy_builder() {
521        let strategy = FileStorageStrategy::new()
522            .with_format(FormatStrategy::Json)
523            .with_retry_count(5)
524            .with_cleanup(false)
525            .with_load_behavior(LoadBehavior::ErrorIfMissing);
526
527        assert_eq!(strategy.format, FormatStrategy::Json);
528        assert_eq!(strategy.atomic_write.retry_count, 5);
529        assert!(!strategy.atomic_write.cleanup_tmp_files);
530        assert_eq!(strategy.load_behavior, LoadBehavior::ErrorIfMissing);
531    }
532
533    #[test]
534    fn test_save_and_load_toml() {
535        let temp_dir = TempDir::new().unwrap();
536        let file_path = temp_dir.path().join("test.toml");
537        let migrator = setup_migrator();
538        let strategy = FileStorageStrategy::default(); // TOML by default
539
540        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
541
542        // Update and save
543        let entities = vec![TestEntity {
544            name: "test".to_string(),
545            count: 42,
546        }];
547        storage.update_and_save("test", entities).unwrap();
548
549        // Create new storage and load from saved file
550        let migrator2 = setup_migrator();
551        let storage2 =
552            FileStorage::new(file_path, migrator2, FileStorageStrategy::default()).unwrap();
553
554        // Query and verify
555        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
556        assert_eq!(loaded.len(), 1);
557        assert_eq!(loaded[0].name, "test");
558        assert_eq!(loaded[0].count, 42);
559    }
560
561    #[test]
562    fn test_save_and_load_json() {
563        let temp_dir = TempDir::new().unwrap();
564        let file_path = temp_dir.path().join("test.json");
565        let migrator = setup_migrator();
566        let strategy = FileStorageStrategy::new().with_format(FormatStrategy::Json);
567
568        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
569
570        // Update and save
571        let entities = vec![TestEntity {
572            name: "json_test".to_string(),
573            count: 100,
574        }];
575        storage.update_and_save("test", entities).unwrap();
576
577        // Create new storage and load from saved file
578        let migrator2 = setup_migrator();
579        let strategy2 = FileStorageStrategy::new().with_format(FormatStrategy::Json);
580        let storage2 = FileStorage::new(file_path, migrator2, strategy2).unwrap();
581
582        // Query and verify
583        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
584        assert_eq!(loaded.len(), 1);
585        assert_eq!(loaded[0].name, "json_test");
586        assert_eq!(loaded[0].count, 100);
587    }
588
589    #[test]
590    fn test_load_behavior_create_if_missing() {
591        let temp_dir = TempDir::new().unwrap();
592        let file_path = temp_dir.path().join("nonexistent.toml");
593        let migrator = setup_migrator();
594        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
595
596        let result = FileStorage::new(file_path, migrator, strategy);
597
598        assert!(result.is_ok()); // Should not error when file doesn't exist
599    }
600
601    #[test]
602    fn test_load_behavior_error_if_missing() {
603        let temp_dir = TempDir::new().unwrap();
604        let file_path = temp_dir.path().join("nonexistent.toml");
605        let migrator = setup_migrator();
606        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
607
608        let result = FileStorage::new(file_path, migrator, strategy);
609
610        assert!(result.is_err()); // Should error when file doesn't exist
611        assert!(matches!(result, Err(MigrationError::IoError { .. })));
612    }
613
614    #[test]
615    fn test_atomic_write_no_tmp_file_left() {
616        let temp_dir = TempDir::new().unwrap();
617        let file_path = temp_dir.path().join("atomic.toml");
618        let migrator = setup_migrator();
619        let strategy = FileStorageStrategy::default();
620
621        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
622
623        let entities = vec![TestEntity {
624            name: "atomic".to_string(),
625            count: 1,
626        }];
627        storage.update_and_save("test", entities).unwrap();
628
629        // Verify no temp file left behind
630        let entries: Vec<_> = fs::read_dir(temp_dir.path())
631            .unwrap()
632            .filter_map(|e| e.ok())
633            .collect();
634
635        let tmp_files: Vec<_> = entries
636            .iter()
637            .filter(|e| {
638                e.file_name()
639                    .to_string_lossy()
640                    .starts_with(".atomic.toml.tmp")
641            })
642            .collect();
643
644        assert_eq!(tmp_files.len(), 0, "Temporary files should be cleaned up");
645    }
646}