Skip to main content

version_migrate/
versioned_dir.rs

1//! Version-aware directory storage wrappers.
2//!
3//! Provides `VersionedDirStorage` (sync) and, when the `async` feature is
4//! enabled, `VersionedAsyncDirStorage`.  Both wrap their counterparts in
5//! `local_store` for raw ACID file operations and layer `Migrator`-based
6//! schema evolution on top.
7
8use crate::{AppPaths, MigrationError, Migrator};
9use local_store::{DirStorageStrategy, FormatStrategy};
10use std::path::Path;
11
12// ============================================================================
13// VersionedDirStorage (sync)
14// ============================================================================
15
16/// Version-aware directory-based entity storage.
17///
18/// Wraps `local_store::DirStorage` for raw IO and layers `Migrator`-based
19/// schema evolution on top.
20///
21/// # Responsibilities
22///
23/// - Serialising/deserialising entities to/from the configured format.
24/// - Delegating all ACID / atomic-rename / lock operations to `inner`.
25/// - Applying migrator-based schema evolution on load.
26///
27/// Raw IO (`atomic_rename`, `get_temp_path`, `cleanup_temp_files`) lives
28/// exclusively inside `local_store::DirStorage`.
29pub struct VersionedDirStorage {
30    /// Raw ACID-safe directory store (no migration knowledge).
31    inner: local_store::DirStorage,
32    /// Migrator for schema evolution on save/load.
33    migrator: Migrator,
34    /// Strategy for format dispatch (JSON / TOML).
35    strategy: DirStorageStrategy,
36}
37
38impl VersionedDirStorage {
39    /// Create a new `VersionedDirStorage` instance.
40    ///
41    /// Resolves the base path as `paths.data_dir()?.join(category)`, creates
42    /// the directory when absent, and wraps the raw `local_store::DirStorage`.
43    ///
44    /// # Arguments
45    ///
46    /// * `paths` - Application paths manager.
47    /// * `category` - Domain-specific subdirectory name (e.g. `"sessions"`).
48    /// * `migrator` - `Migrator` instance with registered migration paths.
49    /// * `strategy` - Storage strategy configuration (format, encoding, etc.).
50    ///
51    /// # Errors
52    ///
53    /// Returns `MigrationError::Store` if directory creation fails.
54    pub fn new(
55        paths: AppPaths,
56        category: impl Into<String>,
57        migrator: Migrator,
58        strategy: DirStorageStrategy,
59    ) -> Result<Self, MigrationError> {
60        let inner = local_store::DirStorage::new(paths, category, strategy.clone())
61            .map_err(MigrationError::Store)?;
62        Ok(Self {
63            inner,
64            migrator,
65            strategy,
66        })
67    }
68
69    /// Save an entity to a file.
70    ///
71    /// Converts the entity to its latest versioned DTO via `Migrator::save_domain_flat`,
72    /// applies format serialisation, and writes atomically through
73    /// `local_store::DirStorage::save_raw_string`.
74    ///
75    /// # Errors
76    ///
77    /// Returns `MigrationError` on serialisation failure, invalid ID characters,
78    /// or IO errors.
79    pub fn save<T>(
80        &self,
81        entity_name: impl Into<String>,
82        id: impl Into<String>,
83        entity: T,
84    ) -> Result<(), MigrationError>
85    where
86        T: serde::Serialize,
87    {
88        let entity_name = entity_name.into();
89        let id = id.into();
90
91        let json_string = self.migrator.save_domain_flat(&entity_name, entity)?;
92
93        let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
94            .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
95
96        let content = self.serialize_content(&versioned_value)?;
97
98        self.inner
99            .save_raw_string(&entity_name, &id, &content)
100            .map_err(store_err_to_migration)
101    }
102
103    /// Load an entity from a file.
104    ///
105    /// Reads the raw string from `local_store::DirStorage::load_raw_string`,
106    /// deserialises the content to a `serde_json::Value`, and migrates to the
107    /// target domain type via `Migrator::load_flat_from`.
108    ///
109    /// # Errors
110    ///
111    /// Returns `MigrationError` if the file is not found, deserialisation fails,
112    /// or migration fails.
113    pub fn load<D>(
114        &self,
115        entity_name: impl Into<String>,
116        id: impl Into<String>,
117    ) -> Result<D, MigrationError>
118    where
119        D: serde::de::DeserializeOwned,
120    {
121        let entity_name = entity_name.into();
122        let id = id.into();
123
124        let content = self
125            .inner
126            .load_raw_string(&id)
127            .map_err(store_err_to_migration)?;
128
129        let value = self.deserialize_content(&content)?;
130        self.migrator.load_flat_from(&entity_name, value)
131    }
132
133    /// List all entity IDs in the storage directory.
134    ///
135    /// Delegates directly to `local_store::DirStorage::list_ids`.
136    ///
137    /// # Errors
138    ///
139    /// Returns `MigrationError::Store` on IO failure or
140    /// `MigrationError::FilenameEncoding` when a stored filename cannot be decoded.
141    pub fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
142        self.inner.list_ids().map_err(store_err_to_migration)
143    }
144
145    /// Load all entities from the storage directory.
146    ///
147    /// Calls `list_ids` and then `load` for each ID.  If any load fails the
148    /// entire operation fails.
149    ///
150    /// # Errors
151    ///
152    /// Returns the first `MigrationError` encountered during loading.
153    pub fn load_all<D>(
154        &self,
155        entity_name: impl Into<String>,
156    ) -> Result<Vec<(String, D)>, MigrationError>
157    where
158        D: serde::de::DeserializeOwned,
159    {
160        let entity_name = entity_name.into();
161        let ids = self.list_ids()?;
162        let mut results = Vec::new();
163        for id in ids {
164            let entity = self.load(&entity_name, &id)?;
165            results.push((id, entity));
166        }
167        Ok(results)
168    }
169
170    /// Check whether an entity file exists.
171    ///
172    /// # Errors
173    ///
174    /// Returns `MigrationError::Store` if ID encoding fails.
175    pub fn exists(&self, id: impl Into<String>) -> Result<bool, MigrationError> {
176        self.inner.exists(id).map_err(store_err_to_migration)
177    }
178
179    /// Delete an entity file.
180    ///
181    /// This operation is idempotent: deleting a non-existent file is not an error.
182    ///
183    /// # Errors
184    ///
185    /// Returns `MigrationError::Store` if file deletion fails.
186    pub fn delete(&self, id: impl Into<String>) -> Result<(), MigrationError> {
187        self.inner.delete(id).map_err(store_err_to_migration)
188    }
189
190    /// Returns a reference to the base directory path.
191    pub fn base_path(&self) -> &Path {
192        self.inner.base_path()
193    }
194
195    // ====================================================================
196    // Private format helpers
197    // ====================================================================
198
199    fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
200        match self.strategy.format {
201            FormatStrategy::Json => serde_json::to_string_pretty(value)
202                .map_err(|e| MigrationError::SerializationError(e.to_string())),
203            FormatStrategy::Toml => {
204                let tv = local_store::format_convert::json_to_toml(value).map_err(|e| {
205                    MigrationError::Store(local_store::StoreError::FormatConvert(e))
206                })?;
207                toml::to_string_pretty(&tv)
208                    .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
209            }
210        }
211    }
212
213    fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
214        match self.strategy.format {
215            FormatStrategy::Json => serde_json::from_str(content)
216                .map_err(|e| MigrationError::DeserializationError(e.to_string())),
217            FormatStrategy::Toml => {
218                let tv: toml::Value = toml::from_str(content)
219                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
220                toml_to_json(tv)
221            }
222        }
223    }
224}
225
226// ============================================================================
227// Format conversion helpers (private, shared with async_impl)
228// ============================================================================
229
230fn toml_to_json(toml_value: toml::Value) -> Result<serde_json::Value, MigrationError> {
231    let json_str = serde_json::to_string(&toml_value)
232        .map_err(|e| MigrationError::SerializationError(e.to_string()))?;
233    let json_value: serde_json::Value = serde_json::from_str(&json_str)
234        .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
235    Ok(json_value)
236}
237
238/// Convert a `local_store::StoreError` to `MigrationError`, promoting
239/// `StoreError::FilenameEncoding` to the dedicated `MigrationError::FilenameEncoding`
240/// variant.
241fn store_err_to_migration(e: local_store::StoreError) -> MigrationError {
242    match e {
243        local_store::StoreError::FilenameEncoding { id, reason } => {
244            MigrationError::FilenameEncoding { id, reason }
245        }
246        other => MigrationError::Store(other),
247    }
248}
249
250// ============================================================================
251// VersionedAsyncDirStorage
252// ============================================================================
253
254#[cfg(feature = "async")]
255pub use async_impl::VersionedAsyncDirStorage;
256
257#[cfg(feature = "async")]
258mod async_impl {
259    use crate::{AppPaths, MigrationError, Migrator};
260    use local_store::DirStorageStrategy;
261    use std::path::Path;
262
263    use super::{store_err_to_migration, toml_to_json, FormatStrategy};
264
265    /// Async version of `VersionedDirStorage`.
266    ///
267    /// Wraps `local_store::AsyncDirStorage` for raw async IO and layers
268    /// `Migrator`-based schema evolution on top.
269    ///
270    /// # Responsibilities
271    ///
272    /// - Serialising/deserialising entities to/from the configured format.
273    /// - Delegating all ACID / atomic-rename / lock operations to `inner`.
274    /// - Applying migrator-based schema evolution on load.
275    pub struct VersionedAsyncDirStorage {
276        /// Raw ACID-safe async directory store (no migration knowledge).
277        inner: local_store::AsyncDirStorage,
278        /// Migrator for schema evolution on save/load.
279        migrator: Migrator,
280        /// Strategy for format dispatch (JSON / TOML).
281        strategy: DirStorageStrategy,
282    }
283
284    impl VersionedAsyncDirStorage {
285        /// Create a new `VersionedAsyncDirStorage` instance (async).
286        ///
287        /// Resolves the base path as `paths.data_dir()?.join(category)`, creates
288        /// the directory when absent, and wraps the raw
289        /// `local_store::AsyncDirStorage`.
290        ///
291        /// # Errors
292        ///
293        /// Returns `MigrationError::Store` if directory creation fails.
294        pub async fn new(
295            paths: AppPaths,
296            category: impl Into<String>,
297            migrator: Migrator,
298            strategy: DirStorageStrategy,
299        ) -> Result<Self, MigrationError> {
300            let inner = local_store::AsyncDirStorage::new(paths, category, strategy.clone())
301                .await
302                .map_err(MigrationError::Store)?;
303            Ok(Self {
304                inner,
305                migrator,
306                strategy,
307            })
308        }
309
310        /// Save an entity to a file (async).
311        ///
312        /// Converts the entity to its latest versioned DTO, applies format
313        /// serialisation, and delegates the atomic write to
314        /// `local_store::AsyncDirStorage::save_raw_string`.
315        ///
316        /// # Errors
317        ///
318        /// Returns `MigrationError` on serialisation failure, invalid ID, or IO errors.
319        pub async fn save<T>(
320            &self,
321            entity_name: impl Into<String>,
322            id: impl Into<String>,
323            entity: T,
324        ) -> Result<(), MigrationError>
325        where
326            T: serde::Serialize,
327        {
328            let entity_name = entity_name.into();
329            let id = id.into();
330
331            let json_string = self.migrator.save_domain_flat(&entity_name, entity)?;
332
333            let versioned_value: serde_json::Value = serde_json::from_str(&json_string)
334                .map_err(|e| MigrationError::DeserializationError(e.to_string()))?;
335
336            let content = self.serialize_content(&versioned_value)?;
337
338            self.inner
339                .save_raw_string(&entity_name, &id, &content)
340                .await
341                .map_err(store_err_to_migration)
342        }
343
344        /// Load an entity from a file (async).
345        ///
346        /// Reads the raw string, deserialises to `serde_json::Value`, and migrates
347        /// to the target domain type.
348        ///
349        /// # Errors
350        ///
351        /// Returns `MigrationError` if the file is not found, deserialisation fails,
352        /// or migration fails.
353        pub async fn load<D>(
354            &self,
355            entity_name: impl Into<String>,
356            id: impl Into<String>,
357        ) -> Result<D, MigrationError>
358        where
359            D: serde::de::DeserializeOwned,
360        {
361            let entity_name = entity_name.into();
362            let id = id.into();
363
364            let content = self
365                .inner
366                .load_raw_string(&id)
367                .await
368                .map_err(store_err_to_migration)?;
369
370            let value = self.deserialize_content(&content)?;
371            self.migrator.load_flat_from(&entity_name, value)
372        }
373
374        /// List all entity IDs in the storage directory (async).
375        ///
376        /// # Errors
377        ///
378        /// Returns `MigrationError::Store` if the directory read fails.
379        pub async fn list_ids(&self) -> Result<Vec<String>, MigrationError> {
380            self.inner.list_ids().await.map_err(store_err_to_migration)
381        }
382
383        /// Load all entities from the storage directory (async).
384        ///
385        /// # Errors
386        ///
387        /// Returns the first `MigrationError` encountered during loading.
388        pub async fn load_all<D>(
389            &self,
390            entity_name: impl Into<String>,
391        ) -> Result<Vec<(String, D)>, MigrationError>
392        where
393            D: serde::de::DeserializeOwned,
394        {
395            let entity_name = entity_name.into();
396            let ids = self.list_ids().await?;
397            let mut results = Vec::new();
398            for id in ids {
399                let entity = self.load(&entity_name, &id).await?;
400                results.push((id, entity));
401            }
402            Ok(results)
403        }
404
405        /// Check whether an entity file exists (async).
406        ///
407        /// # Errors
408        ///
409        /// Returns `MigrationError::Store` on ID encoding failure or IO error.
410        pub async fn exists(&self, id: impl Into<String>) -> Result<bool, MigrationError> {
411            self.inner.exists(id).await.map_err(store_err_to_migration)
412        }
413
414        /// Delete an entity file (async).
415        ///
416        /// This operation is idempotent: deleting a non-existent file is not an error.
417        ///
418        /// # Errors
419        ///
420        /// Returns `MigrationError::Store` if file deletion fails.
421        pub async fn delete(&self, id: impl Into<String>) -> Result<(), MigrationError> {
422            self.inner.delete(id).await.map_err(store_err_to_migration)
423        }
424
425        /// Returns a reference to the base directory path.
426        pub fn base_path(&self) -> &Path {
427            self.inner.base_path()
428        }
429
430        // ================================================================
431        // Private format helpers
432        // ================================================================
433
434        fn serialize_content(&self, value: &serde_json::Value) -> Result<String, MigrationError> {
435            match self.strategy.format {
436                FormatStrategy::Json => serde_json::to_string_pretty(value)
437                    .map_err(|e| MigrationError::SerializationError(e.to_string())),
438                FormatStrategy::Toml => {
439                    let tv = local_store::format_convert::json_to_toml(value).map_err(|e| {
440                        MigrationError::Store(local_store::StoreError::FormatConvert(e))
441                    })?;
442                    toml::to_string_pretty(&tv)
443                        .map_err(|e| MigrationError::TomlSerializeError(e.to_string()))
444                }
445            }
446        }
447
448        fn deserialize_content(&self, content: &str) -> Result<serde_json::Value, MigrationError> {
449            match self.strategy.format {
450                FormatStrategy::Json => serde_json::from_str(content)
451                    .map_err(|e| MigrationError::DeserializationError(e.to_string())),
452                FormatStrategy::Toml => {
453                    let tv: toml::Value = toml::from_str(content)
454                        .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
455                    toml_to_json(tv)
456                }
457            }
458        }
459    }
460}
461
462// ============================================================================
463// Sync tests
464// ============================================================================
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::{AppPaths, FromDomain, IntoDomain, MigratesTo, PathStrategy, Versioned};
470    use serde::{Deserialize, Serialize};
471    use tempfile::TempDir;
472
473    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
474    struct SessionV1_0_0 {
475        id: String,
476        user_id: String,
477    }
478
479    impl Versioned for SessionV1_0_0 {
480        const VERSION: &'static str = "1.0.0";
481    }
482
483    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484    struct SessionV1_1_0 {
485        id: String,
486        user_id: String,
487        created_at: Option<String>,
488    }
489
490    impl Versioned for SessionV1_1_0 {
491        const VERSION: &'static str = "1.1.0";
492    }
493
494    impl MigratesTo<SessionV1_1_0> for SessionV1_0_0 {
495        fn migrate(self) -> SessionV1_1_0 {
496            SessionV1_1_0 {
497                id: self.id,
498                user_id: self.user_id,
499                created_at: None,
500            }
501        }
502    }
503
504    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505    struct SessionEntity {
506        id: String,
507        user_id: String,
508        created_at: Option<String>,
509    }
510
511    impl IntoDomain<SessionEntity> for SessionV1_1_0 {
512        fn into_domain(self) -> SessionEntity {
513            SessionEntity {
514                id: self.id,
515                user_id: self.user_id,
516                created_at: self.created_at,
517            }
518        }
519    }
520
521    impl FromDomain<SessionEntity> for SessionV1_1_0 {
522        fn from_domain(domain: SessionEntity) -> Self {
523            SessionV1_1_0 {
524                id: domain.id,
525                user_id: domain.user_id,
526                created_at: domain.created_at,
527            }
528        }
529    }
530
531    fn setup_session_migrator() -> Migrator {
532        let path = Migrator::define("session")
533            .from::<SessionV1_0_0>()
534            .step::<SessionV1_1_0>()
535            .into_with_save::<SessionEntity>();
536
537        let mut migrator = Migrator::new();
538        // SAFETY: register only fails on circular paths or duplicate entity names.
539        migrator.register(path).unwrap();
540        migrator
541    }
542
543    #[test]
544    fn test_versioned_dir_storage_new_creates_directory() {
545        let temp_dir = TempDir::new().unwrap();
546        let paths = AppPaths::new("testapp")
547            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
548
549        let migrator = Migrator::new();
550        let strategy = DirStorageStrategy::default();
551
552        let storage = VersionedDirStorage::new(paths, "sessions", migrator, strategy).unwrap();
553
554        assert!(storage.base_path().exists());
555        assert!(storage.base_path().is_dir());
556        assert!(storage.base_path().ends_with("data/testapp/sessions"));
557    }
558
559    #[test]
560    fn test_versioned_dir_storage_category_into_string() {
561        // Verify that category accepts impl Into<String> (both &str and String)
562        let temp_dir = TempDir::new().unwrap();
563        let paths = AppPaths::new("testapp")
564            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
565
566        let migrator = Migrator::new();
567        let strategy = DirStorageStrategy::default();
568
569        // &str
570        let result = VersionedDirStorage::new(paths.clone(), "sessions", migrator, strategy);
571        assert!(result.is_ok());
572
573        let migrator2 = Migrator::new();
574        let strategy2 = DirStorageStrategy::default();
575        // String
576        let result2 =
577            VersionedDirStorage::new(paths, String::from("sessions2"), migrator2, strategy2);
578        assert!(result2.is_ok());
579    }
580
581    #[test]
582    fn test_versioned_dir_storage_save_and_load() {
583        let temp_dir = TempDir::new().unwrap();
584        let paths = AppPaths::new("testapp")
585            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
586
587        let migrator = setup_session_migrator();
588        let strategy = DirStorageStrategy::default();
589        let storage = VersionedDirStorage::new(paths, "sessions", migrator, strategy).unwrap();
590
591        let session = SessionEntity {
592            id: "session-1".to_string(),
593            user_id: "user-1".to_string(),
594            created_at: Some("2024-01-01".to_string()),
595        };
596
597        storage
598            .save("session", &session.id, session.clone())
599            .unwrap();
600
601        let loaded: SessionEntity = storage.load("session", "session-1").unwrap();
602        assert_eq!(loaded.id, session.id);
603        assert_eq!(loaded.user_id, session.user_id);
604        assert_eq!(loaded.created_at, session.created_at);
605    }
606
607    #[test]
608    fn test_versioned_dir_storage_list_ids() {
609        let temp_dir = TempDir::new().unwrap();
610        let paths = AppPaths::new("testapp")
611            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
612
613        let migrator = setup_session_migrator();
614        let strategy = DirStorageStrategy::default();
615        let storage = VersionedDirStorage::new(paths, "sessions", migrator, strategy).unwrap();
616
617        let ids = ["session-c", "session-a", "session-b"];
618        for id in &ids {
619            let session = SessionEntity {
620                id: id.to_string(),
621                user_id: "user".to_string(),
622                created_at: None,
623            };
624            storage.save("session", *id, session).unwrap();
625        }
626
627        let listed = storage.list_ids().unwrap();
628        assert_eq!(listed.len(), 3);
629        assert_eq!(listed, vec!["session-a", "session-b", "session-c"]);
630    }
631
632    #[test]
633    fn test_versioned_dir_storage_load_all() {
634        let temp_dir = TempDir::new().unwrap();
635        let paths = AppPaths::new("testapp")
636            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
637
638        let migrator = setup_session_migrator();
639        let strategy = DirStorageStrategy::default();
640        let storage = VersionedDirStorage::new(paths, "sessions", migrator, strategy).unwrap();
641
642        let sessions = vec![
643            SessionEntity {
644                id: "s1".to_string(),
645                user_id: "u1".to_string(),
646                created_at: None,
647            },
648            SessionEntity {
649                id: "s2".to_string(),
650                user_id: "u2".to_string(),
651                created_at: Some("2024-01-01".to_string()),
652            },
653        ];
654
655        for s in &sessions {
656            storage.save("session", &s.id, s.clone()).unwrap();
657        }
658
659        let results: Vec<(String, SessionEntity)> = storage.load_all("session").unwrap();
660        assert_eq!(results.len(), 2);
661    }
662
663    #[test]
664    fn test_versioned_dir_storage_exists_and_delete() {
665        let temp_dir = TempDir::new().unwrap();
666        let paths = AppPaths::new("testapp")
667            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
668
669        let migrator = setup_session_migrator();
670        let strategy = DirStorageStrategy::default();
671        let storage = VersionedDirStorage::new(paths, "sessions", migrator, strategy).unwrap();
672
673        let session = SessionEntity {
674            id: "del-session".to_string(),
675            user_id: "u".to_string(),
676            created_at: None,
677        };
678        storage.save("session", "del-session", session).unwrap();
679
680        assert!(storage.exists("del-session").unwrap());
681        storage.delete("del-session").unwrap();
682        assert!(!storage.exists("del-session").unwrap());
683    }
684
685    #[test]
686    fn test_versioned_dir_storage_base_path() {
687        let temp_dir = TempDir::new().unwrap();
688        let paths = AppPaths::new("myapp")
689            .data_strategy(PathStrategy::CustomBase(temp_dir.path().to_path_buf()));
690
691        let migrator = Migrator::new();
692        let strategy = DirStorageStrategy::default();
693        let storage = VersionedDirStorage::new(paths, "entities", migrator, strategy).unwrap();
694
695        assert!(storage.base_path().ends_with("entities"));
696    }
697}