version_migrate/
storage.rs

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