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