Skip to main content

version_migrate/
versioned_file.rs

1//! Version-aware file storage wrapper.
2//!
3//! Provides `VersionedFileStorage`, which wraps `local_store::FileStorage` for
4//! raw ACID file operations and layers `ConfigMigrator`-based schema evolution
5//! on top.
6
7use crate::{ConfigMigrator, MigrationError, Migrator, Queryable};
8use local_store::{FileStorageStrategy, FormatStrategy, LoadBehavior};
9use serde_json::Value as JsonValue;
10use std::path::{Path, PathBuf};
11
12/// Version-aware file storage that wraps `local_store::FileStorage`.
13///
14/// # Responsibilities
15///
16/// This struct handles **only**:
17/// - Constructing a `ConfigMigrator` from the file content on init.
18/// - Format dispatch (TOML ↔ JSON serialisation) on save.
19/// - Delegating all ACID / atomic-rename / lock operations to `inner`.
20///
21/// Raw IO (`atomic_rename`, `get_temp_path`, `cleanup_temp_files`) lives
22/// exclusively inside `local_store::FileStorage`.
23pub struct VersionedFileStorage {
24    /// Raw ACID-safe file store (no migration knowledge).
25    inner: local_store::FileStorage,
26    /// In-memory versioned configuration (migration layer).
27    config: ConfigMigrator,
28    /// Strategy governing format, load behaviour, etc.
29    strategy: FileStorageStrategy,
30}
31
32impl VersionedFileStorage {
33    /// Create a new `VersionedFileStorage` instance and load data from the file.
34    ///
35    /// Reads the file (if present), applies TOML→JSON conversion when needed,
36    /// constructs a `ConfigMigrator` from the resulting JSON string, and—when
37    /// `LoadBehavior::SaveIfMissing` is configured—writes the initial content to
38    /// disk.
39    ///
40    /// # Arguments
41    ///
42    /// * `path` - Path to the storage file.
43    /// * `migrator` - `Migrator` instance with registered migration paths.
44    /// * `strategy` - Storage strategy (format, load behaviour, atomic-write config).
45    ///
46    /// # Errors
47    ///
48    /// Returns `MigrationError::Store` on IO failure or
49    /// `MigrationError::TomlParseError` / `MigrationError::SerializationError` on
50    /// format conversion failure.  Returns `MigrationError::Store(IoError)` when
51    /// `LoadBehavior::ErrorIfMissing` is set and the file is absent.
52    pub fn new(
53        path: PathBuf,
54        migrator: Migrator,
55        strategy: FileStorageStrategy,
56    ) -> Result<Self, MigrationError> {
57        // Track whether the file existed before we open it.
58        let file_was_missing = !path.exists();
59
60        // Build an inner strategy that always uses CreateIfMissing so the raw
61        // layer does not interfere with our own LoadBehavior logic.
62        let inner_strategy = FileStorageStrategy {
63            load_behavior: LoadBehavior::CreateIfMissing,
64            ..strategy.clone()
65        };
66        let inner = local_store::FileStorage::new(path.clone(), inner_strategy)
67            .map_err(MigrationError::Store)?;
68
69        // Determine the JSON string we hand to ConfigMigrator.
70        let json_string = if !file_was_missing {
71            // File existed: read it and convert to JSON.
72            let raw = inner.read_string().map_err(MigrationError::Store)?;
73            if raw.trim().is_empty() {
74                "{}".to_string()
75            } else {
76                match strategy.format {
77                    FormatStrategy::Toml => {
78                        let tv: toml::Value = toml::from_str(&raw)
79                            .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
80                        let jv = toml_to_json(tv)?;
81                        serde_json::to_string(&jv)
82                            .map_err(|e| MigrationError::SerializationError(e.to_string()))?
83                    }
84                    FormatStrategy::Json => raw,
85                }
86            }
87        } else {
88            // File was missing: apply LoadBehavior.
89            match strategy.load_behavior {
90                LoadBehavior::ErrorIfMissing => {
91                    return Err(MigrationError::Store(local_store::StoreError::IoError {
92                        operation: local_store::IoOperationKind::Read,
93                        path: path.display().to_string(),
94                        context: None,
95                        error: "File not found".to_string(),
96                    }));
97                }
98                LoadBehavior::CreateIfMissing | LoadBehavior::SaveIfMissing => {
99                    if let Some(ref default_value) = strategy.default_value {
100                        serde_json::to_string(default_value)
101                            .map_err(|e| MigrationError::SerializationError(e.to_string()))?
102                    } else {
103                        "{}".to_string()
104                    }
105                }
106            }
107        };
108
109        let config = ConfigMigrator::from(&json_string, migrator)?;
110        let storage = Self {
111            inner,
112            config,
113            strategy,
114        };
115
116        // When SaveIfMissing is set and the file was absent, persist now.
117        if file_was_missing && storage.strategy.load_behavior == LoadBehavior::SaveIfMissing {
118            storage.save()?;
119        }
120
121        Ok(storage)
122    }
123
124    /// Save the current in-memory state to the file atomically.
125    ///
126    /// Serialises the `ConfigMigrator` value to the configured format (TOML or
127    /// JSON) and delegates the atomic write (tmp file + fsync + rename) to
128    /// `local_store::FileStorage::write_string`.
129    ///
130    /// # Errors
131    ///
132    /// Returns `MigrationError::Store` on IO failure or
133    /// `MigrationError::TomlSerializeError` / `MigrationError::SerializationError`
134    /// on serialisation failure.
135    pub fn save(&self) -> Result<(), MigrationError> {
136        let json_value = self.config.as_value();
137
138        let content = match self.strategy.format {
139            FormatStrategy::Toml => {
140                let tv = local_store::format_convert::json_to_toml(json_value).map_err(|e| {
141                    MigrationError::Store(local_store::StoreError::FormatConvert(e))
142                })?;
143                toml::to_string_pretty(&tv)
144                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))?
145            }
146            FormatStrategy::Json => serde_json::to_string_pretty(json_value)
147                .map_err(|e| MigrationError::SerializationError(e.to_string()))?,
148        };
149
150        self.inner
151            .write_string(&content)
152            .map_err(MigrationError::Store)
153    }
154
155    /// Get an immutable reference to the `ConfigMigrator`.
156    pub fn config(&self) -> &ConfigMigrator {
157        &self.config
158    }
159
160    /// Get a mutable reference to the `ConfigMigrator`.
161    pub fn config_mut(&mut self) -> &mut ConfigMigrator {
162        &mut self.config
163    }
164
165    /// Query entities from the in-memory configuration.
166    ///
167    /// Delegates to `ConfigMigrator::query`.
168    ///
169    /// # Errors
170    ///
171    /// Returns `MigrationError` if the key is not found or deserialisation fails.
172    pub fn query<T>(&self, key: &str) -> Result<Vec<T>, MigrationError>
173    where
174        T: Queryable + for<'de> serde::Deserialize<'de>,
175    {
176        self.config.query(key)
177    }
178
179    /// Update entities in memory without saving to disk.
180    ///
181    /// Delegates to `ConfigMigrator::update`.
182    ///
183    /// # Errors
184    ///
185    /// Returns `MigrationError` if serialisation or internal update fails.
186    pub fn update<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
187    where
188        T: Queryable + serde::Serialize,
189    {
190        self.config.update(key, value)
191    }
192
193    /// Update entities in memory and immediately save to disk atomically.
194    ///
195    /// Combines `update` and `save` in a single operation.
196    ///
197    /// # Errors
198    ///
199    /// Returns `MigrationError` on update or IO failure.
200    pub fn update_and_save<T>(&mut self, key: &str, value: Vec<T>) -> Result<(), MigrationError>
201    where
202        T: Queryable + serde::Serialize,
203    {
204        self.update(key, value)?;
205        self.save()
206    }
207
208    /// Returns a reference to the storage file path.
209    pub fn path(&self) -> &Path {
210        self.inner.path()
211    }
212}
213
214// ============================================================================
215// Private format-conversion helpers
216// ============================================================================
217
218/// Convert a `toml::Value` to a `serde_json::Value`.
219fn toml_to_json(toml_value: toml::Value) -> Result<JsonValue, MigrationError> {
220    let json_str = serde_json::to_string(&toml_value)
221        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
222    let json_value: JsonValue = serde_json::from_str(&json_str)
223        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
224    Ok(json_value)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::{IntoDomain, MigratesTo, Versioned};
231    use serde::{Deserialize, Serialize};
232    use tempfile::TempDir;
233
234    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235    struct TestEntity {
236        name: String,
237        count: u32,
238    }
239
240    impl Queryable for TestEntity {
241        const ENTITY_NAME: &'static str = "test";
242    }
243
244    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245    struct TestV1 {
246        name: String,
247    }
248
249    impl Versioned for TestV1 {
250        const VERSION: &'static str = "1.0.0";
251    }
252
253    impl MigratesTo<TestV2> for TestV1 {
254        fn migrate(self) -> TestV2 {
255            TestV2 {
256                name: self.name,
257                count: 0,
258            }
259        }
260    }
261
262    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263    struct TestV2 {
264        name: String,
265        count: u32,
266    }
267
268    impl Versioned for TestV2 {
269        const VERSION: &'static str = "2.0.0";
270    }
271
272    impl IntoDomain<TestEntity> for TestV2 {
273        fn into_domain(self) -> TestEntity {
274            TestEntity {
275                name: self.name,
276                count: self.count,
277            }
278        }
279    }
280
281    fn setup_migrator() -> Migrator {
282        let path = Migrator::define("test")
283            .from::<TestV1>()
284            .step::<TestV2>()
285            .into::<TestEntity>();
286
287        let mut migrator = Migrator::new();
288        // SAFETY: register only fails on circular paths or duplicate entity names,
289        // neither of which applies to this static test setup.
290        migrator.register(path).unwrap();
291        migrator
292    }
293
294    #[test]
295    fn test_versioned_file_storage_new_create_if_missing() {
296        let temp_dir = TempDir::new().unwrap();
297        let file_path = temp_dir.path().join("nonexistent.toml");
298        let migrator = setup_migrator();
299        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::CreateIfMissing);
300
301        let result = VersionedFileStorage::new(file_path, migrator, strategy);
302        assert!(result.is_ok());
303    }
304
305    #[test]
306    fn test_versioned_file_storage_new_error_if_missing() {
307        let temp_dir = TempDir::new().unwrap();
308        let file_path = temp_dir.path().join("nonexistent.toml");
309        let migrator = setup_migrator();
310        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::ErrorIfMissing);
311
312        let result = VersionedFileStorage::new(file_path, migrator, strategy);
313        assert!(result.is_err());
314        assert!(matches!(
315            result,
316            Err(MigrationError::Store(
317                local_store::StoreError::IoError { .. }
318            ))
319        ));
320    }
321
322    #[test]
323    fn test_versioned_file_storage_save_and_reload() {
324        let temp_dir = TempDir::new().unwrap();
325        let file_path = temp_dir.path().join("data.toml");
326        let migrator = setup_migrator();
327        let strategy = FileStorageStrategy::default();
328
329        let mut storage = VersionedFileStorage::new(file_path.clone(), migrator, strategy).unwrap();
330
331        let entities = vec![TestEntity {
332            name: "hello".to_string(),
333            count: 7,
334        }];
335        storage.update_and_save("test", entities).unwrap();
336
337        let migrator2 = setup_migrator();
338        let storage2 =
339            VersionedFileStorage::new(file_path, migrator2, FileStorageStrategy::default())
340                .unwrap();
341
342        let loaded: Vec<TestEntity> = storage2.query("test").unwrap();
343        assert_eq!(loaded.len(), 1);
344        assert_eq!(loaded[0].name, "hello");
345        assert_eq!(loaded[0].count, 7);
346    }
347
348    #[test]
349    fn test_versioned_file_storage_save_if_missing() {
350        let temp_dir = TempDir::new().unwrap();
351        let file_path = temp_dir.path().join("save_if_missing.toml");
352        let migrator = setup_migrator();
353        let strategy = FileStorageStrategy::new().with_load_behavior(LoadBehavior::SaveIfMissing);
354
355        assert!(!file_path.exists());
356
357        let result = VersionedFileStorage::new(file_path.clone(), migrator, strategy);
358        assert!(result.is_ok());
359        assert!(file_path.exists());
360    }
361
362    #[test]
363    fn test_versioned_file_storage_path() {
364        let temp_dir = TempDir::new().unwrap();
365        let file_path = temp_dir.path().join("config.toml");
366        let migrator = setup_migrator();
367
368        let storage =
369            VersionedFileStorage::new(file_path.clone(), migrator, FileStorageStrategy::default())
370                .unwrap();
371
372        assert_eq!(storage.path(), file_path.as_path());
373    }
374
375    #[test]
376    fn test_versioned_file_storage_config_accessors() {
377        let temp_dir = TempDir::new().unwrap();
378        let file_path = temp_dir.path().join("config.toml");
379        let migrator = setup_migrator();
380
381        let mut storage =
382            VersionedFileStorage::new(file_path, migrator, FileStorageStrategy::default()).unwrap();
383
384        let _config = storage.config();
385        let _config_mut = storage.config_mut();
386    }
387}