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::{IoOperationKind, 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#[cfg(feature = "async")]
159pub use dir_storage::AsyncDirStorage;
160
161// Re-export paths types
162pub use paths::{AppPaths, PathStrategy, PrefPath};
163
164// Re-export async-trait for user convenience
165#[cfg(feature = "async")]
166pub use async_trait::async_trait;
167
168/// A trait for versioned data schemas.
169///
170/// This trait marks a type as representing a specific version of a data schema.
171/// It should be derived using `#[derive(Versioned)]` along with the `#[versioned(version = "x.y.z")]` attribute.
172///
173/// # Custom Keys
174///
175/// You can customize the serialization keys:
176///
177/// ```ignore
178/// #[derive(Versioned)]
179/// #[versioned(
180/// version = "1.0.0",
181/// version_key = "schema_version",
182/// data_key = "payload"
183/// )]
184/// struct Task { ... }
185/// // Serializes to: {"schema_version":"1.0.0","payload":{...}}
186/// ```
187pub trait Versioned {
188 /// The semantic version of this schema.
189 const VERSION: &'static str;
190
191 /// The key name for the version field in serialized data.
192 /// Defaults to "version".
193 const VERSION_KEY: &'static str = "version";
194
195 /// The key name for the data field in serialized data.
196 /// Defaults to "data".
197 const DATA_KEY: &'static str = "data";
198}
199
200/// Defines explicit migration logic from one version to another.
201///
202/// Implementing this trait establishes a migration path from `Self` (the source version)
203/// to `T` (the target version).
204pub trait MigratesTo<T: Versioned>: Versioned {
205 /// Migrates from the current version to the target version.
206 fn migrate(self) -> T;
207}
208
209/// Converts a versioned DTO into the application's domain model.
210///
211/// This trait should be implemented on the latest version of a DTO to convert
212/// it into the clean, version-agnostic domain model.
213pub trait IntoDomain<D>: Versioned {
214 /// Converts this versioned data into the domain model.
215 fn into_domain(self) -> D;
216}
217
218/// Converts a domain model back into a versioned DTO.
219///
220/// This trait should be implemented on versioned DTOs to enable conversion
221/// from the domain model back to the versioned format for serialization.
222///
223/// # Example
224///
225/// ```ignore
226/// impl FromDomain<TaskEntity> for TaskV1_1_0 {
227/// fn from_domain(domain: TaskEntity) -> Self {
228/// TaskV1_1_0 {
229/// id: domain.id,
230/// title: domain.title,
231/// description: domain.description,
232/// }
233/// }
234/// }
235/// ```
236pub trait FromDomain<D>: Versioned + Serialize {
237 /// Converts a domain model into this versioned format.
238 fn from_domain(domain: D) -> Self;
239}
240
241/// Associates a domain entity with its latest versioned representation.
242///
243/// This trait enables automatic saving of domain entities using their latest version.
244/// It should typically be derived using the `#[version_migrate]` attribute macro.
245///
246/// # Example
247///
248/// ```ignore
249/// #[derive(Serialize, Deserialize)]
250/// #[version_migrate(entity = "task", latest = TaskV1_1_0)]
251/// struct TaskEntity {
252/// id: String,
253/// title: String,
254/// description: Option<String>,
255/// }
256///
257/// // Now you can save entities directly
258/// let entity = TaskEntity { ... };
259/// let json = migrator.save_entity(entity)?;
260/// ```
261pub trait LatestVersioned: Sized {
262 /// The latest versioned type for this entity.
263 type Latest: Versioned + Serialize + FromDomain<Self>;
264
265 /// The entity name used for migration paths.
266 const ENTITY_NAME: &'static str;
267
268 /// Converts this domain entity into its latest versioned format.
269 fn to_latest(self) -> Self::Latest {
270 Self::Latest::from_domain(self)
271 }
272}
273
274/// Marks a domain type as queryable, associating it with an entity name.
275///
276/// This trait enables `ConfigMigrator` to automatically determine which entity
277/// path to use when querying or updating data.
278///
279/// # Example
280///
281/// ```ignore
282/// impl Queryable for TaskEntity {
283/// const ENTITY_NAME: &'static str = "task";
284/// }
285///
286/// let tasks: Vec<TaskEntity> = config.query("tasks")?;
287/// ```
288pub trait Queryable {
289 /// The entity name used to look up migration paths in the `Migrator`.
290 const ENTITY_NAME: &'static str;
291}
292
293/// Async version of `MigratesTo` for migrations requiring I/O operations.
294///
295/// Use this trait when migrations need to perform asynchronous operations
296/// such as database queries or API calls.
297#[cfg(feature = "async")]
298#[async_trait::async_trait]
299pub trait AsyncMigratesTo<T: Versioned>: Versioned + Send {
300 /// Asynchronously migrates from the current version to the target version.
301 ///
302 /// # Errors
303 ///
304 /// Returns `MigrationError` if the migration fails.
305 async fn migrate(self) -> Result<T, MigrationError>;
306}
307
308/// Async version of `IntoDomain` for domain conversions requiring I/O operations.
309///
310/// Use this trait when converting to the domain model requires asynchronous
311/// operations such as fetching additional data from external sources.
312#[cfg(feature = "async")]
313#[async_trait::async_trait]
314pub trait AsyncIntoDomain<D>: Versioned + Send {
315 /// Asynchronously converts this versioned data into the domain model.
316 ///
317 /// # Errors
318 ///
319 /// Returns `MigrationError` if the conversion fails.
320 async fn into_domain(self) -> Result<D, MigrationError>;
321}
322
323/// A wrapper for serialized data that includes explicit version information.
324///
325/// This struct is used for persistence to ensure that the version of the data
326/// is always stored alongside the data itself.
327#[derive(Serialize, Deserialize, Debug, Clone)]
328pub struct VersionedWrapper<T> {
329 /// The semantic version of the data.
330 pub version: String,
331 /// The actual data.
332 pub data: T,
333}
334
335impl<T> VersionedWrapper<T> {
336 /// Creates a new versioned wrapper with the specified version and data.
337 pub fn new(version: String, data: T) -> Self {
338 Self { version, data }
339 }
340}
341
342impl<T: Versioned> VersionedWrapper<T> {
343 /// Creates a wrapper from a versioned value, automatically extracting its version.
344 pub fn from_versioned(data: T) -> Self {
345 Self {
346 version: T::VERSION.to_string(),
347 data,
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
357 struct TestData {
358 value: String,
359 }
360
361 impl Versioned for TestData {
362 const VERSION: &'static str = "1.0.0";
363 }
364
365 #[test]
366 fn test_versioned_wrapper_from_versioned() {
367 let data = TestData {
368 value: "test".to_string(),
369 };
370 let wrapper = VersionedWrapper::from_versioned(data);
371
372 assert_eq!(wrapper.version, "1.0.0");
373 assert_eq!(wrapper.data.value, "test");
374 }
375
376 #[test]
377 fn test_versioned_wrapper_new() {
378 let data = TestData {
379 value: "manual".to_string(),
380 };
381 let wrapper = VersionedWrapper::new("2.0.0".to_string(), data);
382
383 assert_eq!(wrapper.version, "2.0.0");
384 assert_eq!(wrapper.data.value, "manual");
385 }
386
387 #[test]
388 fn test_versioned_wrapper_serialization() {
389 let data = TestData {
390 value: "serialize_test".to_string(),
391 };
392 let wrapper = VersionedWrapper::from_versioned(data);
393
394 // Serialize
395 let json = serde_json::to_string(&wrapper).expect("Serialization failed");
396
397 // Deserialize
398 let deserialized: VersionedWrapper<TestData> =
399 serde_json::from_str(&json).expect("Deserialization failed");
400
401 assert_eq!(deserialized.version, "1.0.0");
402 assert_eq!(deserialized.data.value, "serialize_test");
403 }
404
405 #[test]
406 fn test_versioned_wrapper_with_complex_data() {
407 #[derive(Serialize, Deserialize, Debug, PartialEq)]
408 struct ComplexData {
409 id: u64,
410 name: String,
411 tags: Vec<String>,
412 metadata: Option<String>,
413 }
414
415 impl Versioned for ComplexData {
416 const VERSION: &'static str = "3.2.1";
417 }
418
419 let data = ComplexData {
420 id: 42,
421 name: "complex".to_string(),
422 tags: vec!["tag1".to_string(), "tag2".to_string()],
423 metadata: Some("meta".to_string()),
424 };
425
426 let wrapper = VersionedWrapper::from_versioned(data);
427 assert_eq!(wrapper.version, "3.2.1");
428 assert_eq!(wrapper.data.id, 42);
429 assert_eq!(wrapper.data.tags.len(), 2);
430 }
431
432 #[test]
433 fn test_versioned_wrapper_clone() {
434 let data = TestData {
435 value: "clone_test".to_string(),
436 };
437 let wrapper = VersionedWrapper::from_versioned(data);
438 let cloned = wrapper.clone();
439
440 assert_eq!(cloned.version, wrapper.version);
441 assert_eq!(cloned.data.value, wrapper.data.value);
442 }
443
444 #[test]
445 fn test_versioned_wrapper_debug() {
446 let data = TestData {
447 value: "debug".to_string(),
448 };
449 let wrapper = VersionedWrapper::from_versioned(data);
450 let debug_str = format!("{:?}", wrapper);
451
452 assert!(debug_str.contains("1.0.0"));
453 assert!(debug_str.contains("debug"));
454 }
455}