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}