Skip to main content

version_migrate/
storage.rs

1//! File storage layer with migration support.
2//!
3//! Provides `FileStorage`, which wraps `local_store::FileStorage` for raw ACID
4//! file operations and layers `ConfigMigrator`-based schema evolution on top.
5
6use crate::{ConfigMigrator, MigrationError, Migrator, Queryable};
7use local_store::{FileStorageStrategy, FormatStrategy, LoadBehavior};
8use serde_json::Value as JsonValue;
9use std::path::{Path, PathBuf};
10
11/// File storage with ACID guarantees and automatic migrations.
12///
13/// Provides:
14/// - **Atomicity**: Updates are all-or-nothing via tmp file + atomic rename
15/// - **Consistency**: Format validation on load/save
16/// - **Isolation**: File locking prevents concurrent modifications
17/// - **Durability**: Explicit fsync before rename
18///
19/// Raw IO (`atomic_rename`, `get_temp_path`, `cleanup_temp_files`) lives
20/// exclusively inside `local_store::FileStorage`.
21pub struct FileStorage {
22    /// Raw ACID-safe file store (no migration knowledge).
23    inner: local_store::FileStorage,
24    /// In-memory versioned configuration (migration layer).
25    config: ConfigMigrator,
26    /// Strategy governing format, load behaviour, etc.
27    strategy: FileStorageStrategy,
28}
29
30impl FileStorage {
31    /// Create a new FileStorage instance and load data from file.
32    ///
33    /// This combines initialization and loading into a single operation.
34    ///
35    /// # Arguments
36    ///
37    /// * `path` - Path to the storage file
38    /// * `migrator` - Migrator instance with registered migration paths
39    /// * `strategy` - Storage strategy configuration
40    ///
41    /// # Behavior
42    ///
43    /// Depends on `strategy.load_behavior`:
44    /// - `CreateIfMissing`: Creates empty config if file doesn't exist
45    /// - `SaveIfMissing`: Creates empty config and saves it if file doesn't exist
46    /// - `ErrorIfMissing`: Returns error if file doesn't exist
47    pub fn new(
48        path: PathBuf,
49        migrator: Migrator,
50        strategy: FileStorageStrategy,
51    ) -> Result<Self, MigrationError> {
52        // Track whether the file existed before we open it.
53        let file_was_missing = !path.exists();
54
55        // Build an inner strategy that always uses CreateIfMissing so the raw
56        // layer does not interfere with our own LoadBehavior logic.
57        let inner_strategy = FileStorageStrategy {
58            load_behavior: LoadBehavior::CreateIfMissing,
59            ..strategy.clone()
60        };
61        let inner = local_store::FileStorage::new(path.clone(), inner_strategy)
62            .map_err(MigrationError::Store)?;
63
64        // Determine the JSON string we hand to ConfigMigrator.
65        let json_string = if !file_was_missing {
66            // File existed: read it and convert to JSON.
67            let raw = inner.read_string().map_err(MigrationError::Store)?;
68            if raw.trim().is_empty() {
69                "{}".to_string()
70            } else {
71                match strategy.format {
72                    FormatStrategy::Toml => {
73                        let tv: toml::Value = toml::from_str(&raw)
74                            .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
75                        let jv = toml_to_json(tv)?;
76                        serde_json::to_string(&jv)
77                            .map_err(|e| MigrationError::SerializationError(e.to_string()))?
78                    }
79                    FormatStrategy::Json => raw,
80                }
81            }
82        } else {
83            // File was missing: apply LoadBehavior.
84            match strategy.load_behavior {
85                LoadBehavior::ErrorIfMissing => {
86                    return Err(MigrationError::Store(local_store::StoreError::IoError {
87                        operation: local_store::IoOperationKind::Read,
88                        path: path.display().to_string(),
89                        context: None,
90                        error: "File not found".to_string(),
91                    }));
92                }
93                LoadBehavior::CreateIfMissing | LoadBehavior::SaveIfMissing => {
94                    if let Some(ref default_value) = strategy.default_value {
95                        serde_json::to_string(default_value)
96                            .map_err(|e| MigrationError::SerializationError(e.to_string()))?
97                    } else {
98                        "{}".to_string()
99                    }
100                }
101            }
102        };
103
104        let config = ConfigMigrator::from(&json_string, migrator)?;
105        let storage = Self {
106            inner,
107            config,
108            strategy,
109        };
110
111        // When SaveIfMissing is set and the file was absent, persist now.
112        if file_was_missing && storage.strategy.load_behavior == LoadBehavior::SaveIfMissing {
113            storage.save()?;
114        }
115
116        Ok(storage)
117    }
118
119    /// Save current state to file atomically.
120    ///
121    /// Serialises the `ConfigMigrator` value to the configured format (TOML or
122    /// JSON) and delegates the atomic write (tmp file + fsync + rename) to
123    /// `local_store::FileStorage::write_string`.
124    pub fn save(&self) -> Result<(), MigrationError> {
125        let json_value = self.config.as_value();
126
127        let content = match self.strategy.format {
128            FormatStrategy::Toml => {
129                let tv = local_store::json_to_toml(json_value).map_err(|e| {
130                    MigrationError::Store(local_store::StoreError::FormatConvert(e))
131                })?;
132                toml::to_string_pretty(&tv)
133                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))?
134            }
135            FormatStrategy::Json => serde_json::to_string_pretty(json_value)
136                .map_err(|e| MigrationError::SerializationError(e.to_string()))?,
137        };
138
139        self.inner
140            .write_string(&content)
141            .map_err(MigrationError::Store)
142    }
143
144    /// Get immutable reference to the ConfigMigrator.
145    pub fn config(&self) -> &ConfigMigrator {
146        &self.config
147    }
148
149    /// Get mutable reference to the ConfigMigrator.
150    pub fn config_mut(&mut self) -> &mut ConfigMigrator {
151        &mut self.config
152    }
153
154    /// Query entities from storage.
155    ///
156    /// Delegates to `ConfigMigrator::query()`.
157    pub fn query<T>(&self, key: &str) -> Result<Vec<T>, MigrationError>
158    where
159        T: Queryable + for<'de> serde::Deserialize<'de>,
160    {
161        self.config.query(key)
162    }
163
164    /// Update entities in memory (does not save to file).
165    ///
166    /// Delegates to `ConfigMigrator::update()`.
167    pub fn update<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
168    where
169        T: Queryable + serde::Serialize,
170    {
171        self.config.update(key, value)
172    }
173
174    /// Update entities and immediately save to file atomically.
175    pub fn update_and_save<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
176    where
177        T: Queryable + serde::Serialize,
178    {
179        self.update(key, value)?;
180        self.save()
181    }
182
183    /// Returns a reference to the storage file path.
184    ///
185    /// # Returns
186    ///
187    /// A reference to the file path where the configuration is stored.
188    pub fn path(&self) -> &Path {
189        self.inner.path()
190    }
191}
192
193// ============================================================================
194// Private format-conversion helpers
195// ============================================================================
196
197/// Convert a `toml::Value` to a `serde_json::Value`.
198fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
199    let json_str = serde_json::to_string(&toml_value)
200        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
201    let json_value: JsonValue = serde_json::from_str(&json_str)
202        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
203    Ok(json_value)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::{IntoDomain, MigratesTo, Versioned};
210    use serde::{Deserialize, Serialize};
211    use tempfile::TempDir;
212
213    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214    struct TestEntity {
215        name: String,
216        count: u32,
217    }
218
219    impl Queryable for TestEntity {
220        const ENTITY_NAME: &'static str = "test";
221    }
222
223    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224    struct TestV1 {
225        name: String,
226    }
227
228    impl Versioned for TestV1 {
229        const VERSION: &'static str = "1.0.0";
230    }
231
232    impl MigratesTo<TestV2> for TestV1 {
233        fn migrate(self) -> TestV2 {
234            TestV2 {
235                name: self.name,
236                count: 0,
237            }
238        }
239    }
240
241    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
242    struct TestV2 {
243        name: String,
244        count: u32,
245    }
246
247    impl Versioned for TestV2 {
248        const VERSION: &'static str = "2.0.0";
249    }
250
251    impl IntoDomain<TestEntity> for TestV2 {
252        fn into_domain(self) -> TestEntity {
253            TestEntity {
254                name: self.name,
255                count: self.count,
256            }
257        }
258    }
259
260    fn setup_migrator() -> Migrator {
261        let path = Migrator::define("test")
262            .from::<TestV1>()
263            .step::<TestV2>()
264            .into::<TestEntity>();
265
266        let mut migrator = Migrator::new();
267        migrator.register(path).unwrap();
268        migrator
269    }
270
271    #[test]
272    fn test_file_storage_strategy_builder() {
273        let strategy = FileStorageStrategy::new()
274            .with_format(FormatStrategy::Json)
275            .with_retry_count(5)
276            .with_cleanup(false)
277            .with_load_behavior(LoadBehavior::ErrorIfMissing);
278
279        assert_eq!(strategy.format, FormatStrategy::Json);
280        assert_eq!(strategy.atomic_write.retry_count, 5);
281        assert!(!strategy.atomic_write.cleanup_tmp_files);
282        assert_eq!(strategy.load_behavior, LoadBehavior::ErrorIfMissing);
283    }
284
285    #[test]
286    fn test_save_and_load_toml() {
287        let temp_dir = TempDir::new().unwrap();
288        let file_path = temp_dir.path().join("test.toml");
289        let migrator = setup_migrator();
290        let strategy = FileStorageStrategy::default(); // TOML by default
291
292        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
293
294        // Update and save
295        let entities = vec![TestEntity {
296            name: "test".to_string(),
297            count: 42,
298        }];
299        storage.update_and_save("test", entities).unwrap();
300
301        // Create new storage and load from saved file
302        let migrator2 = setup_migrator();
303        let storage2 =
304            FileStorage::new(file_path, migrator2, FileStorageStrategy::default()).unwrap();
305
306        // Query and verify
307        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
308        assert_eq!(loaded.len(), 1);
309        assert_eq!(loaded[0].name, "test");
310        assert_eq!(loaded[0].count, 42);
311    }
312
313    #[test]
314    fn test_save_and_load_json() {
315        let temp_dir = TempDir::new().unwrap();
316        let file_path = temp_dir.path().join("test.json");
317        let migrator = setup_migrator();
318        let strategy = FileStorageStrategy::new().with_format(FormatStrategy::Json);
319
320        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
321
322        // Update and save
323        let entities = vec![TestEntity {
324            name: "json_test".to_string(),
325            count: 100,
326        }];
327        storage.update_and_save("test", entities).unwrap();
328
329        // Create new storage and load from saved file
330        let migrator2 = setup_migrator();
331        let strategy2 = FileStorageStrategy::new().with_format(FormatStrategy::Json);
332        let storage2 = FileStorage::new(file_path, migrator2, strategy2).unwrap();
333
334        // Query and verify
335        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
336        assert_eq!(loaded.len(), 1);
337        assert_eq!(loaded[0].name, "json_test");
338        assert_eq!(loaded[0].count, 100);
339    }
340
341    #[test]
342    fn test_load_behavior_create_if_missing() {
343        let temp_dir = TempDir::new().unwrap();
344        let file_path = temp_dir.path().join("nonexistent.toml");
345        let migrator = setup_migrator();
346        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
347
348        let result = FileStorage::new(file_path, migrator, strategy);
349
350        assert!(result.is_ok()); // Should not error when file doesn't exist
351    }
352
353    #[test]
354    fn test_load_behavior_error_if_missing() {
355        let temp_dir = TempDir::new().unwrap();
356        let file_path = temp_dir.path().join("nonexistent.toml");
357        let migrator = setup_migrator();
358        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
359
360        let result = FileStorage::new(file_path, migrator, strategy);
361
362        assert!(result.is_err()); // Should error when file doesn't exist
363        assert!(matches!(
364            result,
365            Err(MigrationError::Store(
366                local_store::StoreError::IoError { .. }
367            ))
368        ));
369    }
370
371    #[test]
372    fn test_load_behavior_save_if_missing() {
373        let temp_dir = TempDir::new().unwrap();
374        let file_path = temp_dir.path().join("save_if_missing.toml");
375        let migrator = setup_migrator();
376        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::SaveIfMissing);
377
378        // File should not exist initially
379        assert!(!file_path.exists());
380
381        let result = FileStorage::new(file_path.clone(), migrator, strategy.clone());
382
383        // Should succeed and create the file
384        assert!(result.is_ok());
385        assert!(file_path.exists());
386
387        // Verify we can read the file back
388        let _storage = result.unwrap();
389        let reloaded = FileStorage::new(file_path.clone(), setup_migrator(), strategy);
390        assert!(reloaded.is_ok());
391    }
392
393    #[test]
394    fn test_save_if_missing_with_default_value() {
395        let temp_dir = TempDir::new().unwrap();
396        let file_path = temp_dir.path().join("default_value.toml");
397        let migrator = setup_migrator();
398
399        // Create default value with version info (using the latest version 2.0.0)
400        let default_value = serde_json::json!({
401            "test": [
402                {
403                    "version": "2.0.0",
404                    "name": "default_user",
405                    "count": 99
406                }
407            ]
408        });
409
410        let strategy = FileStorageStrategy::new()
411            .with_load_behavior(LoadBehavior::SaveIfMissing)
412            .with_default_value(default_value);
413
414        // File should not exist initially
415        assert!(!file_path.exists());
416
417        let storage = FileStorage::new(file_path.clone(), migrator, strategy.clone()).unwrap();
418
419        // File should have been created
420        assert!(file_path.exists());
421
422        // Verify the default value was saved
423        let loaded: Vec<TestEntity> = storage.query("test").unwrap();
424        assert_eq!(loaded.len(), 1);
425        assert_eq!(loaded[0].name, "default_user");
426        assert_eq!(loaded[0].count, 99);
427
428        // Load again and verify persistence
429        let reloaded = FileStorage::new(file_path.clone(), setup_migrator(), strategy).unwrap();
430        let reloaded_entities: Vec<TestEntity> = reloaded.query("test").unwrap();
431        assert_eq!(reloaded_entities.len(), 1);
432        assert_eq!(reloaded_entities[0].name, "default_user");
433        assert_eq!(reloaded_entities[0].count, 99);
434    }
435
436    #[test]
437    fn test_create_if_missing_with_default_value() {
438        let temp_dir = TempDir::new().unwrap();
439        let file_path = temp_dir.path().join("create_default.toml");
440        let migrator = setup_migrator();
441
442        let default_value = serde_json::json!({
443            "test": [{
444                "version": "2.0.0",
445                "name": "created",
446                "count": 42
447            }]
448        });
449
450        let strategy = FileStorageStrategy::new()
451            .with_load_behavior(LoadBehavior::CreateIfMissing)
452            .with_default_value(default_value);
453
454        // CreateIfMissing should not save the file automatically
455        let storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
456
457        // Query should work with the default value in memory
458        let loaded: Vec<TestEntity> = storage.query("test").unwrap();
459        assert_eq!(loaded.len(), 1);
460        assert_eq!(loaded[0].name, "created");
461        assert_eq!(loaded[0].count, 42);
462    }
463
464    #[test]
465    fn test_atomic_write_no_tmp_file_left() {
466        let temp_dir = TempDir::new().unwrap();
467        let file_path = temp_dir.path().join("atomic.toml");
468        let migrator = setup_migrator();
469        let strategy = FileStorageStrategy::default();
470
471        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
472
473        let entities = vec![TestEntity {
474            name: "atomic".to_string(),
475            count: 1,
476        }];
477        storage.update_and_save("test", entities).unwrap();
478
479        // Verify no temp file left behind
480        let entries: Vec<_> = std::fs::read_dir(temp_dir.path())
481            .unwrap()
482            .filter_map(|e| e.ok())
483            .collect();
484
485        let tmp_files: Vec<_> = entries
486            .iter()
487            .filter(|e| {
488                e.file_name()
489                    .to_string_lossy()
490                    .starts_with(".atomic.toml.tmp")
491            })
492            .collect();
493
494        assert_eq!(tmp_files.len(), 0, "Temporary files should be cleaned up");
495    }
496
497    #[test]
498    fn test_file_storage_path() {
499        let temp_dir = TempDir::new().unwrap();
500        let file_path = temp_dir.path().join("test_config.toml");
501        let migrator = setup_migrator();
502        let strategy = FileStorageStrategy::default();
503
504        let storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
505
506        // Verify path() returns the expected path
507        let returned_path = storage.path();
508        assert_eq!(returned_path, file_path.as_path());
509    }
510
511    #[test]
512    fn test_load_empty_file() {
513        let temp_dir = TempDir::new().unwrap();
514        let file_path = temp_dir.path().join("empty.toml");
515
516        // Create an empty file
517        std::fs::write(&file_path, "").unwrap();
518
519        let migrator = setup_migrator();
520        let strategy = FileStorageStrategy::default();
521
522        // Should handle empty file gracefully (treat as empty JSON {})
523        let result = FileStorage::new(file_path, migrator, strategy);
524        assert!(result.is_ok());
525    }
526
527    #[test]
528    fn test_load_whitespace_only_file() {
529        let temp_dir = TempDir::new().unwrap();
530        let file_path = temp_dir.path().join("whitespace.toml");
531
532        // Create a file with only whitespace
533        std::fs::write(&file_path, "   \n\t\n  ").unwrap();
534
535        let migrator = setup_migrator();
536        let strategy = FileStorageStrategy::default();
537
538        // Should handle whitespace-only file gracefully
539        let result = FileStorage::new(file_path, migrator, strategy);
540        assert!(result.is_ok());
541    }
542
543    #[test]
544    fn test_config_accessors() {
545        let temp_dir = TempDir::new().unwrap();
546        let file_path = temp_dir.path().join("config_access.toml");
547        let migrator = setup_migrator();
548        let strategy = FileStorageStrategy::default();
549
550        let mut storage = FileStorage::new(file_path, migrator, strategy).unwrap();
551
552        // Test config() immutable access
553        let _config = storage.config();
554
555        // Test config_mut() mutable access
556        let _config_mut = storage.config_mut();
557    }
558
559    #[test]
560    fn test_save_creates_parent_directory() {
561        let temp_dir = TempDir::new().unwrap();
562        // Create a path with non-existent parent directory
563        let file_path = temp_dir
564            .path()
565            .join("subdir")
566            .join("nested")
567            .join("config.toml");
568        let migrator = setup_migrator();
569        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
570
571        let storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
572
573        // Save should create parent directories
574        storage.save().unwrap();
575
576        assert!(file_path.exists());
577        assert!(file_path.parent().unwrap().exists());
578    }
579
580    #[test]
581    fn test_cleanup_with_multiple_temp_files() {
582        let temp_dir = TempDir::new().unwrap();
583        let file_path = temp_dir.path().join("cleanup_test.toml");
584        let migrator = setup_migrator();
585        let strategy = FileStorageStrategy::default();
586
587        // Create some fake old temp files
588        let fake_tmp1 = temp_dir.path().join(".cleanup_test.toml.tmp.99999");
589        let fake_tmp2 = temp_dir.path().join(".cleanup_test.toml.tmp.88888");
590        std::fs::write(&fake_tmp1, "old temp 1").unwrap();
591        std::fs::write(&fake_tmp2, "old temp 2").unwrap();
592
593        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
594
595        // Update and save - should cleanup old temp files
596        let entities = vec![TestEntity {
597            name: "cleanup".to_string(),
598            count: 1,
599        }];
600        storage.update_and_save("test", entities).unwrap();
601
602        // Old temp files should be cleaned up
603        assert!(!fake_tmp1.exists());
604        assert!(!fake_tmp2.exists());
605    }
606
607    #[test]
608    fn test_save_without_cleanup() {
609        let temp_dir = TempDir::new().unwrap();
610        let file_path = temp_dir.path().join("no_cleanup.toml");
611        let migrator = setup_migrator();
612        let strategy = FileStorageStrategy::new().with_cleanup(false);
613
614        // Create a fake old temp file
615        let fake_tmp = temp_dir.path().join(".no_cleanup.toml.tmp.99999");
616        std::fs::write(&fake_tmp, "old temp").unwrap();
617
618        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
619
620        let entities = vec![TestEntity {
621            name: "no_cleanup".to_string(),
622            count: 1,
623        }];
624        storage.update_and_save("test", entities).unwrap();
625
626        // Old temp file should NOT be cleaned up when cleanup is disabled
627        assert!(fake_tmp.exists());
628    }
629
630    #[test]
631    fn test_update_without_save() {
632        let temp_dir = TempDir::new().unwrap();
633        let file_path = temp_dir.path().join("update_no_save.toml");
634        let migrator = setup_migrator();
635        let strategy = FileStorageStrategy::default();
636
637        let mut storage = FileStorage::new(file_path.clone(), migrator, strategy).unwrap();
638
639        // Update without save
640        let entities = vec![TestEntity {
641            name: "memory_only".to_string(),
642            count: 42,
643        }];
644        storage.update("test", entities).unwrap();
645
646        // Query should return the updated data (in memory)
647        let loaded: Vec<TestEntity> = storage.query("test").unwrap();
648        assert_eq!(loaded.len(), 1);
649        assert_eq!(loaded[0].name, "memory_only");
650
651        // File should not exist (never saved)
652        assert!(!file_path.exists());
653    }
654
655    #[test]
656    fn test_atomic_write_config_default() {
657        let config = local_store::AtomicWriteConfig::default();
658        assert_eq!(config.retry_count, 3);
659        assert!(config.cleanup_tmp_files);
660    }
661
662    #[test]
663    fn test_format_strategy_equality() {
664        assert_eq!(FormatStrategy::Toml, FormatStrategy::Toml);
665        assert_eq!(FormatStrategy::Json, FormatStrategy::Json);
666        assert_ne!(FormatStrategy::Toml, FormatStrategy::Json);
667    }
668
669    #[test]
670    fn test_load_behavior_equality() {
671        assert_eq!(LoadBehavior::CreateIfMissing, LoadBehavior::CreateIfMissing);
672        assert_eq!(LoadBehavior::SaveIfMissing, LoadBehavior::SaveIfMissing);
673        assert_eq!(LoadBehavior::ErrorIfMissing, LoadBehavior::ErrorIfMissing);
674        assert_ne!(LoadBehavior::CreateIfMissing, LoadBehavior::ErrorIfMissing);
675    }
676}