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    /// 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                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                // Empty file, use empty JSON
156                "{}".to_string()
157            } else {
158                // Parse based on format strategy
159                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            // File doesn't exist
172            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        // Create ConfigMigrator with loaded/empty data
186        let config = ConfigMigrator::from(&json_string, migrator)?;
187
188        Ok(Self {
189            path,
190            config,
191            strategy,
192        })
193    }
194
195    /// Save current state to file atomically.
196    ///
197    /// Uses a temporary file + atomic rename to ensure durability.
198    /// Retries according to `strategy.atomic_write.retry_count`.
199    pub fn save(&self) -> Result<(), MigrationError> {
200        // Ensure parent directory exists
201        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        // Get current state as JSON
213        let json_value = self.config.as_value();
214
215        // Convert to target format
216        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        // Write to temporary file
227        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        // Ensure data is written to disk
245        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        // Atomic rename with retry
255        self.atomic_rename(&tmp_path)?;
256
257        // Cleanup old temp files (best effort)
258        if self.strategy.atomic_write.cleanup_tmp_files {
259            let _ = self.cleanup_temp_files();
260        }
261
262        Ok(())
263    }
264
265    /// Get immutable reference to the ConfigMigrator.
266    pub fn config(&self) -> &ConfigMigrator {
267        &self.config
268    }
269
270    /// Get mutable reference to the ConfigMigrator.
271    pub fn config_mut(&mut self) -> &mut ConfigMigrator {
272        &mut self.config
273    }
274
275    /// Query entities from storage.
276    ///
277    /// Delegates to `ConfigMigrator::query()`.
278    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    /// Update entities in memory (does not save to file).
286    ///
287    /// Delegates to `ConfigMigrator::update()`.
288    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    /// Update entities and immediately save to file atomically.
296    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    /// Returns a reference to the storage file path.
305    ///
306    /// # Returns
307    ///
308    /// A reference to the file path where the configuration is stored.
309    pub fn path(&self) -> &Path {
310        &self.path
311    }
312
313    /// Get path to temporary file for atomic writes.
314    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    /// Atomically rename temporary file to target path with retry.
333    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                        // Small delay before retry
343                        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    /// Clean up old temporary files (best effort).
361    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                        // Try to remove, but ignore errors (best effort)
379                        let _ = fs::remove_file(entry.path());
380                    }
381                }
382            }
383        }
384
385        Ok(())
386    }
387}
388
389/// File lock guard that automatically releases the lock when dropped.
390///
391/// Currently unused, but reserved for future concurrent access features.
392#[allow(dead_code)]
393struct FileLock {
394    file: File,
395    lock_path: PathBuf,
396}
397
398#[allow(dead_code)]
399impl FileLock {
400    /// Acquire an exclusive lock on the given path.
401    fn acquire(path: &Path) -> Result<Self, MigrationError> {
402        // Create lock file path
403        let lock_path = path.with_extension("lock");
404
405        // Ensure parent directory exists
406        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        // Open or create lock file
416        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        // Try to acquire exclusive lock with fs2
427        #[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            // On non-Unix systems, we don't have file locking
440            // This is acceptable for single-user desktop apps
441            // For production use on Windows, consider using advisory locking
442        }
443
444        Ok(FileLock { file, lock_path })
445    }
446}
447
448impl Drop for FileLock {
449    fn drop(&mut self) {
450        // Unlock is automatic when the file handle is dropped on Unix
451        // Try to remove lock file (best effort)
452        let _ = fs::remove_file(&self.lock_path);
453    }
454}
455
456/// Convert toml::Value to serde_json::Value.
457fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
458    // Serialize toml::Value to JSON string, then parse as serde_json::Value
459    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
466/// Convert serde_json::Value to toml::Value.
467fn json_to_toml(json_value: &JsonValue) -> Result<toml::Value, MigrationError> {
468    // Serialize serde_json::Value to JSON string, then parse as toml::Value
469    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(); // TOML by default
561
562        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
563
564        // Update and save
565        let entities = vec![TestEntity {
566            name: "test".to_string(),
567            count: 42,
568        }];
569        storage.update_and_save("test", entities).unwrap();
570
571        // Create new storage and load from saved file
572        let migrator2 = setup_migrator();
573        let storage2 =
574            FileStorage::new(file_path, migrator2, FileStorageStrategy::default()).unwrap();
575
576        // Query and verify
577        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        // Update and save
593        let entities = vec![TestEntity {
594            name: "json_test".to_string(),
595            count: 100,
596        }];
597        storage.update_and_save("test", entities).unwrap();
598
599        // Create new storage and load from saved file
600        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        // Query and verify
605        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()); // Should not error when file doesn't exist
621    }
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()); // Should error when file doesn't exist
633        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        // Verify no temp file left behind
652        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        // Verify path() returns the expected path
679        let returned_path = storage.path();
680        assert_eq!(returned_path, file_path.as_path());
681    }
682}