version_migrate/
lib.rs

1//! # version-migrate
2//!
3//! A library for explicit, type-safe schema versioning and migration.
4//!
5//! ## Features
6//!
7//! - **Type-safe migrations**: Define migrations between versions using traits
8//! - **Validation**: Automatic validation of migration paths (circular path detection, version ordering)
9//! - **Multi-format support**: Load from JSON, TOML, YAML, or any serde-compatible format
10//! - **Legacy data support**: Automatic fallback for data without version information
11//! - **Vec support**: Migrate collections of versioned entities
12//! - **Hierarchical structures**: Support for nested versioned entities
13//! - **Async migrations**: Optional async support for I/O-heavy migrations
14//!
15//! ## Basic Example
16//!
17//! ```ignore
18//! use version_migrate::{Versioned, MigratesTo, IntoDomain, Migrator};
19//! use serde::{Serialize, Deserialize};
20//!
21//! // Version 1.0.0
22//! #[derive(Serialize, Deserialize, Versioned)]
23//! #[versioned(version = "1.0.0")]
24//! struct TaskV1_0_0 {
25//!     id: String,
26//!     title: String,
27//! }
28//!
29//! // Version 1.1.0
30//! #[derive(Serialize, Deserialize, Versioned)]
31//! #[versioned(version = "1.1.0")]
32//! struct TaskV1_1_0 {
33//!     id: String,
34//!     title: String,
35//!     description: Option<String>,
36//! }
37//!
38//! // Domain model
39//! struct TaskEntity {
40//!     id: String,
41//!     title: String,
42//!     description: Option<String>,
43//! }
44//!
45//! impl MigratesTo<TaskV1_1_0> for TaskV1_0_0 {
46//!     fn migrate(self) -> TaskV1_1_0 {
47//!         TaskV1_1_0 {
48//!             id: self.id,
49//!             title: self.title,
50//!             description: None,
51//!         }
52//!     }
53//! }
54//!
55//! impl IntoDomain<TaskEntity> for TaskV1_1_0 {
56//!     fn into_domain(self) -> TaskEntity {
57//!         TaskEntity {
58//!             id: self.id,
59//!             title: self.title,
60//!             description: self.description,
61//!         }
62//!     }
63//! }
64//! ```
65//!
66//! ## Working with Collections (Vec)
67//!
68//! ```ignore
69//! // Save multiple versioned entities
70//! let tasks = vec![
71//!     TaskV1_0_0 { id: "1".into(), title: "Task 1".into() },
72//!     TaskV1_0_0 { id: "2".into(), title: "Task 2".into() },
73//! ];
74//! let json = migrator.save_vec(tasks)?;
75//!
76//! // Load and migrate multiple entities
77//! let domains: Vec<TaskEntity> = migrator.load_vec("task", &json)?;
78//! ```
79//!
80//! ## Legacy Data Support
81//!
82//! Handle data that was created before versioning was introduced:
83//!
84//! ```ignore
85//! // Legacy data without version information
86//! let legacy_json = r#"{"id": "task-1", "title": "Legacy Task"}"#;
87//!
88//! // Automatically treats legacy data as the first version and migrates
89//! let domain: TaskEntity = migrator.load_with_fallback("task", legacy_json)?;
90//!
91//! // Also works with properly versioned data
92//! let versioned_json = r#"{"version":"1.0.0","data":{"id":"task-1","title":"My Task"}}"#;
93//! let domain: TaskEntity = migrator.load_with_fallback("task", versioned_json)?;
94//! ```
95//!
96//! ## Hierarchical Structures
97//!
98//! For complex configurations with nested versioned entities:
99//!
100//! ```ignore
101//! #[derive(Serialize, Deserialize, Versioned)]
102//! #[versioned(version = "1.0.0")]
103//! struct ConfigV1 {
104//!     setting: SettingV1,
105//!     items: Vec<ItemV1>,
106//! }
107//!
108//! #[derive(Serialize, Deserialize, Versioned)]
109//! #[versioned(version = "2.0.0")]
110//! struct ConfigV2 {
111//!     setting: SettingV2,
112//!     items: Vec<ItemV2>,
113//! }
114//!
115//! impl MigratesTo<ConfigV2> for ConfigV1 {
116//!     fn migrate(self) -> ConfigV2 {
117//!         ConfigV2 {
118//!             // Migrate nested entities
119//!             setting: self.setting.migrate(),
120//!             items: self.items.into_iter()
121//!                 .map(|item| item.migrate())
122//!                 .collect(),
123//!         }
124//!     }
125//! }
126//! ```
127//!
128//! ## Design Philosophy
129//!
130//! This library follows the **explicit versioning** approach:
131//!
132//! - Each version has its own type (V1, V2, V3, etc.)
133//! - Migration logic is explicit and testable
134//! - Version changes are tracked in code
135//! - Root-level versioning ensures consistency
136//!
137//! This differs from ProtoBuf's "append-only" approach but allows for:
138//! - Schema refactoring and cleanup
139//! - Type-safe migration paths
140//! - Clear version history in code
141
142use serde::{Deserialize, Serialize};
143
144pub mod dir_storage;
145pub mod errors;
146mod migrator;
147pub mod paths;
148pub mod storage;
149
150// Re-export the derive macros
151pub use version_migrate_macro::Versioned;
152
153// Re-export Queryable derive macro (same name as trait is OK in Rust)
154#[doc(inline)]
155pub use version_migrate_macro::Queryable as DeriveQueryable;
156
157// Re-export VersionMigrate derive macro
158#[doc(inline)]
159pub use version_migrate_macro::VersionMigrate;
160
161// Re-export error types
162pub use errors::{IoOperationKind, MigrationError};
163
164// Re-export migrator types
165pub use migrator::{ConfigMigrator, MigrationPath, Migrator};
166
167// Re-export storage types
168pub use storage::{
169    AtomicWriteConfig, FileStorage, FileStorageStrategy, FormatStrategy, LoadBehavior,
170};
171
172// Re-export dir_storage types
173pub use dir_storage::{DirStorage, DirStorageStrategy, FilenameEncoding};
174
175#[cfg(feature = "async")]
176pub use dir_storage::AsyncDirStorage;
177
178// Re-export paths types
179pub use paths::{AppPaths, PathStrategy, PrefPath};
180
181// Re-export async-trait for user convenience
182#[cfg(feature = "async")]
183pub use async_trait::async_trait;
184
185/// A trait for versioned data schemas.
186///
187/// This trait marks a type as representing a specific version of a data schema.
188/// It should be derived using `#[derive(Versioned)]` along with the `#[versioned(version = "x.y.z")]` attribute.
189///
190/// # Custom Keys
191///
192/// You can customize the serialization keys:
193///
194/// ```ignore
195/// #[derive(Versioned)]
196/// #[versioned(
197///     version = "1.0.0",
198///     version_key = "schema_version",
199///     data_key = "payload"
200/// )]
201/// struct Task { ... }
202/// // Serializes to: {"schema_version":"1.0.0","payload":{...}}
203/// ```
204pub trait Versioned {
205    /// The semantic version of this schema.
206    const VERSION: &'static str;
207
208    /// The key name for the version field in serialized data.
209    /// Defaults to "version".
210    const VERSION_KEY: &'static str = "version";
211
212    /// The key name for the data field in serialized data.
213    /// Defaults to "data".
214    const DATA_KEY: &'static str = "data";
215}
216
217/// Defines explicit migration logic from one version to another.
218///
219/// Implementing this trait establishes a migration path from `Self` (the source version)
220/// to `T` (the target version).
221pub trait MigratesTo<T: Versioned>: Versioned {
222    /// Migrates from the current version to the target version.
223    fn migrate(self) -> T;
224}
225
226/// Converts a versioned DTO into the application's domain model.
227///
228/// This trait should be implemented on the latest version of a DTO to convert
229/// it into the clean, version-agnostic domain model.
230pub trait IntoDomain<D>: Versioned {
231    /// Converts this versioned data into the domain model.
232    fn into_domain(self) -> D;
233}
234
235/// Converts a domain model back into a versioned DTO.
236///
237/// This trait should be implemented on versioned DTOs to enable conversion
238/// from the domain model back to the versioned format for serialization.
239///
240/// # Example
241///
242/// ```ignore
243/// impl FromDomain<TaskEntity> for TaskV1_1_0 {
244///     fn from_domain(domain: TaskEntity) -> Self {
245///         TaskV1_1_0 {
246///             id: domain.id,
247///             title: domain.title,
248///             description: domain.description,
249///         }
250///     }
251/// }
252/// ```
253pub trait FromDomain<D>: Versioned + Serialize {
254    /// Converts a domain model into this versioned format.
255    fn from_domain(domain: D) -> Self;
256}
257
258/// Associates a domain entity with its latest versioned representation.
259///
260/// This trait enables automatic saving of domain entities using their latest version.
261/// It should typically be derived using the `#[version_migrate]` attribute macro.
262///
263/// # Example
264///
265/// ```ignore
266/// #[derive(Serialize, Deserialize)]
267/// #[version_migrate(entity = "task", latest = TaskV1_1_0)]
268/// struct TaskEntity {
269///     id: String,
270///     title: String,
271///     description: Option<String>,
272/// }
273///
274/// // Now you can save entities directly
275/// let entity = TaskEntity { ... };
276/// let json = migrator.save_entity(entity)?;
277/// ```
278pub trait LatestVersioned: Sized {
279    /// The latest versioned type for this entity.
280    type Latest: Versioned + Serialize + FromDomain<Self>;
281
282    /// The entity name used for migration paths.
283    const ENTITY_NAME: &'static str;
284
285    /// Converts this domain entity into its latest versioned format.
286    fn to_latest(self) -> Self::Latest {
287        Self::Latest::from_domain(self)
288    }
289}
290
291/// Marks a domain type as queryable, associating it with an entity name.
292///
293/// This trait enables `ConfigMigrator` to automatically determine which entity
294/// path to use when querying or updating data.
295///
296/// # Example
297///
298/// ```ignore
299/// impl Queryable for TaskEntity {
300///     const ENTITY_NAME: &'static str = "task";
301/// }
302///
303/// let tasks: Vec<TaskEntity> = config.query("tasks")?;
304/// ```
305pub trait Queryable {
306    /// The entity name used to look up migration paths in the `Migrator`.
307    const ENTITY_NAME: &'static str;
308}
309
310/// Async version of `MigratesTo` for migrations requiring I/O operations.
311///
312/// Use this trait when migrations need to perform asynchronous operations
313/// such as database queries or API calls.
314#[cfg(feature = "async")]
315#[async_trait::async_trait]
316pub trait AsyncMigratesTo<T: Versioned>: Versioned + Send {
317    /// Asynchronously migrates from the current version to the target version.
318    ///
319    /// # Errors
320    ///
321    /// Returns `MigrationError` if the migration fails.
322    async fn migrate(self) -> Result<T, MigrationError>;
323}
324
325/// Async version of `IntoDomain` for domain conversions requiring I/O operations.
326///
327/// Use this trait when converting to the domain model requires asynchronous
328/// operations such as fetching additional data from external sources.
329#[cfg(feature = "async")]
330#[async_trait::async_trait]
331pub trait AsyncIntoDomain<D>: Versioned + Send {
332    /// Asynchronously converts this versioned data into the domain model.
333    ///
334    /// # Errors
335    ///
336    /// Returns `MigrationError` if the conversion fails.
337    async fn into_domain(self) -> Result<D, MigrationError>;
338}
339
340/// A wrapper for serialized data that includes explicit version information.
341///
342/// This struct is used for persistence to ensure that the version of the data
343/// is always stored alongside the data itself.
344#[derive(Serialize, Deserialize, Debug, Clone)]
345pub struct VersionedWrapper<T> {
346    /// The semantic version of the data.
347    pub version: String,
348    /// The actual data.
349    pub data: T,
350}
351
352impl<T> VersionedWrapper<T> {
353    /// Creates a new versioned wrapper with the specified version and data.
354    pub fn new(version: String, data: T) -> Self {
355        Self { version, data }
356    }
357}
358
359impl<T: Versioned> VersionedWrapper<T> {
360    /// Creates a wrapper from a versioned value, automatically extracting its version.
361    pub fn from_versioned(data: T) -> Self {
362        Self {
363            version: T::VERSION.to_string(),
364            data,
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
374    struct TestData {
375        value: String,
376    }
377
378    impl Versioned for TestData {
379        const VERSION: &'static str = "1.0.0";
380    }
381
382    #[test]
383    fn test_versioned_wrapper_from_versioned() {
384        let data = TestData {
385            value: "test".to_string(),
386        };
387        let wrapper = VersionedWrapper::from_versioned(data);
388
389        assert_eq!(wrapper.version, "1.0.0");
390        assert_eq!(wrapper.data.value, "test");
391    }
392
393    #[test]
394    fn test_versioned_wrapper_new() {
395        let data = TestData {
396            value: "manual".to_string(),
397        };
398        let wrapper = VersionedWrapper::new("2.0.0".to_string(), data);
399
400        assert_eq!(wrapper.version, "2.0.0");
401        assert_eq!(wrapper.data.value, "manual");
402    }
403
404    #[test]
405    fn test_versioned_wrapper_serialization() {
406        let data = TestData {
407            value: "serialize_test".to_string(),
408        };
409        let wrapper = VersionedWrapper::from_versioned(data);
410
411        // Serialize
412        let json = serde_json::to_string(&wrapper).expect("Serialization failed");
413
414        // Deserialize
415        let deserialized: VersionedWrapper<TestData> =
416            serde_json::from_str(&json).expect("Deserialization failed");
417
418        assert_eq!(deserialized.version, "1.0.0");
419        assert_eq!(deserialized.data.value, "serialize_test");
420    }
421
422    #[test]
423    fn test_versioned_wrapper_with_complex_data() {
424        #[derive(Serialize, Deserialize, Debug, PartialEq)]
425        struct ComplexData {
426            id: u64,
427            name: String,
428            tags: Vec<String>,
429            metadata: Option<String>,
430        }
431
432        impl Versioned for ComplexData {
433            const VERSION: &'static str = "3.2.1";
434        }
435
436        let data = ComplexData {
437            id: 42,
438            name: "complex".to_string(),
439            tags: vec!["tag1".to_string(), "tag2".to_string()],
440            metadata: Some("meta".to_string()),
441        };
442
443        let wrapper = VersionedWrapper::from_versioned(data);
444        assert_eq!(wrapper.version, "3.2.1");
445        assert_eq!(wrapper.data.id, 42);
446        assert_eq!(wrapper.data.tags.len(), 2);
447    }
448
449    #[test]
450    fn test_versioned_wrapper_clone() {
451        let data = TestData {
452            value: "clone_test".to_string(),
453        };
454        let wrapper = VersionedWrapper::from_versioned(data);
455        let cloned = wrapper.clone();
456
457        assert_eq!(cloned.version, wrapper.version);
458        assert_eq!(cloned.data.value, wrapper.data.value);
459    }
460
461    #[test]
462    fn test_versioned_wrapper_debug() {
463        let data = TestData {
464            value: "debug".to_string(),
465        };
466        let wrapper = VersionedWrapper::from_versioned(data);
467        let debug_str = format!("{:?}", wrapper);
468
469        assert!(debug_str.contains("1.0.0"));
470        assert!(debug_str.contains("debug"));
471    }
472}