Skip to main content

oximedia_workflow/
workflow_version.rs

1//! Workflow versioning and migration support.
2//!
3//! Provides `WorkflowVersion`, `VersionMigration`, and
4//! `WorkflowVersionRegistry` for managing schema evolution of stored
5//! workflow definitions.
6
7#![allow(dead_code)]
8
9use std::collections::HashMap;
10
11// ---------------------------------------------------------------------------
12// WorkflowVersion
13// ---------------------------------------------------------------------------
14
15/// A semantic version for a workflow definition schema.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct WorkflowVersion {
18    /// Major component — incompatible API changes.
19    pub major: u32,
20    /// Minor component — backwards-compatible feature additions.
21    pub minor: u32,
22    /// Patch component — backwards-compatible bug fixes.
23    pub patch: u32,
24}
25
26impl WorkflowVersion {
27    /// Construct a new version.
28    #[must_use]
29    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
30        Self {
31            major,
32            minor,
33            patch,
34        }
35    }
36
37    /// Parse a `"major.minor.patch"` string.
38    ///
39    /// Returns `None` if the string is malformed.
40    #[must_use]
41    pub fn parse(s: &str) -> Option<Self> {
42        let parts: Vec<&str> = s.split('.').collect();
43        if parts.len() != 3 {
44            return None;
45        }
46        let major = parts[0].parse().ok()?;
47        let minor = parts[1].parse().ok()?;
48        let patch = parts[2].parse().ok()?;
49        Some(Self {
50            major,
51            minor,
52            patch,
53        })
54    }
55
56    /// Returns `true` if `other` is compatible with `self`.
57    ///
58    /// Compatibility requires the same major version and a minor ≥ own minor.
59    #[must_use]
60    pub fn is_compatible_with(&self, other: &Self) -> bool {
61        self.major == other.major && other.minor >= self.minor
62    }
63
64    /// Formatted string representation.
65    #[must_use]
66    pub fn to_string_repr(&self) -> String {
67        format!("{}.{}.{}", self.major, self.minor, self.patch)
68    }
69}
70
71impl std::fmt::Display for WorkflowVersion {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
74    }
75}
76
77// ---------------------------------------------------------------------------
78// VersionMigration
79// ---------------------------------------------------------------------------
80
81/// A migration that transforms a workflow payload from one version to another.
82#[derive(Debug, Clone)]
83pub struct VersionMigration {
84    /// The schema version this migration originates from.
85    pub from: WorkflowVersion,
86    /// The schema version this migration produces.
87    pub to: WorkflowVersion,
88    /// Short description of what the migration does.
89    pub description: String,
90}
91
92impl VersionMigration {
93    /// Create a new migration descriptor.
94    #[must_use]
95    pub fn new(from: WorkflowVersion, to: WorkflowVersion, description: impl Into<String>) -> Self {
96        Self {
97            from,
98            to,
99            description: description.into(),
100        }
101    }
102
103    /// Source version of this migration.
104    #[must_use]
105    pub fn from_version(&self) -> &WorkflowVersion {
106        &self.from
107    }
108
109    /// Target version of this migration.
110    #[must_use]
111    pub fn to_version(&self) -> &WorkflowVersion {
112        &self.to
113    }
114
115    /// Returns `true` if this migration is a major-version bump.
116    #[must_use]
117    pub fn is_major_migration(&self) -> bool {
118        self.from.major != self.to.major
119    }
120}
121
122// ---------------------------------------------------------------------------
123// WorkflowVersionRegistry
124// ---------------------------------------------------------------------------
125
126/// Registry that tracks all known workflow versions and the migrations
127/// between them.
128#[derive(Debug, Default)]
129pub struct WorkflowVersionRegistry {
130    versions: Vec<WorkflowVersion>,
131    migrations: HashMap<(WorkflowVersion, WorkflowVersion), VersionMigration>,
132}
133
134impl WorkflowVersionRegistry {
135    /// Create an empty registry.
136    #[must_use]
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Register a known version.
142    pub fn register(&mut self, version: WorkflowVersion) {
143        if !self.versions.contains(&version) {
144            self.versions.push(version.clone());
145            self.versions.sort();
146        }
147    }
148
149    /// Add a migration between two registered versions.
150    pub fn add_migration(&mut self, migration: VersionMigration) {
151        self.register(migration.from.clone());
152        self.register(migration.to.clone());
153        let key = (migration.from.clone(), migration.to.clone());
154        self.migrations.insert(key, migration);
155    }
156
157    /// Return the highest registered version, or `None` if empty.
158    #[must_use]
159    pub fn latest(&self) -> Option<&WorkflowVersion> {
160        self.versions.last()
161    }
162
163    /// Retrieve the migration from `from` to `to`, if registered.
164    #[must_use]
165    pub fn migrate(
166        &self,
167        from: &WorkflowVersion,
168        to: &WorkflowVersion,
169    ) -> Option<&VersionMigration> {
170        self.migrations.get(&(from.clone(), to.clone()))
171    }
172
173    /// All registered versions, sorted ascending.
174    #[must_use]
175    pub fn all_versions(&self) -> &[WorkflowVersion] {
176        &self.versions
177    }
178
179    /// All registered migrations.
180    #[must_use]
181    pub fn all_migrations(&self) -> Vec<&VersionMigration> {
182        self.migrations.values().collect()
183    }
184
185    /// Number of registered versions.
186    #[must_use]
187    pub fn version_count(&self) -> usize {
188        self.versions.len()
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_version_new_and_display() {
202        let v = WorkflowVersion::new(1, 2, 3);
203        assert_eq!(v.to_string(), "1.2.3");
204    }
205
206    #[test]
207    fn test_version_parse_valid() {
208        let v = WorkflowVersion::parse("2.0.1").expect("should succeed in test");
209        assert_eq!(v.major, 2);
210        assert_eq!(v.minor, 0);
211        assert_eq!(v.patch, 1);
212    }
213
214    #[test]
215    fn test_version_parse_invalid() {
216        assert!(WorkflowVersion::parse("1.2").is_none());
217        assert!(WorkflowVersion::parse("abc").is_none());
218        assert!(WorkflowVersion::parse("1.x.3").is_none());
219    }
220
221    #[test]
222    fn test_version_ordering() {
223        let v1 = WorkflowVersion::new(1, 0, 0);
224        let v2 = WorkflowVersion::new(1, 1, 0);
225        let v3 = WorkflowVersion::new(2, 0, 0);
226        assert!(v1 < v2);
227        assert!(v2 < v3);
228    }
229
230    #[test]
231    fn test_is_compatible_with_same_major() {
232        let v1 = WorkflowVersion::new(1, 2, 0);
233        let v2 = WorkflowVersion::new(1, 3, 0);
234        assert!(v1.is_compatible_with(&v2)); // v2 minor >= v1 minor
235    }
236
237    #[test]
238    fn test_is_compatible_with_older_minor() {
239        let v1 = WorkflowVersion::new(1, 3, 0);
240        let v2 = WorkflowVersion::new(1, 2, 0);
241        assert!(!v1.is_compatible_with(&v2)); // v2 minor < v1 minor
242    }
243
244    #[test]
245    fn test_is_compatible_different_major() {
246        let v1 = WorkflowVersion::new(1, 0, 0);
247        let v2 = WorkflowVersion::new(2, 0, 0);
248        assert!(!v1.is_compatible_with(&v2));
249    }
250
251    #[test]
252    fn test_migration_from_version() {
253        let from = WorkflowVersion::new(1, 0, 0);
254        let to = WorkflowVersion::new(1, 1, 0);
255        let m = VersionMigration::new(from.clone(), to, "add field");
256        assert_eq!(m.from_version(), &from);
257    }
258
259    #[test]
260    fn test_migration_is_major() {
261        let m = VersionMigration::new(
262            WorkflowVersion::new(1, 0, 0),
263            WorkflowVersion::new(2, 0, 0),
264            "major bump",
265        );
266        assert!(m.is_major_migration());
267    }
268
269    #[test]
270    fn test_migration_not_major() {
271        let m = VersionMigration::new(
272            WorkflowVersion::new(1, 0, 0),
273            WorkflowVersion::new(1, 1, 0),
274            "minor bump",
275        );
276        assert!(!m.is_major_migration());
277    }
278
279    #[test]
280    fn test_registry_register_and_latest() {
281        let mut reg = WorkflowVersionRegistry::new();
282        reg.register(WorkflowVersion::new(1, 0, 0));
283        reg.register(WorkflowVersion::new(1, 1, 0));
284        reg.register(WorkflowVersion::new(2, 0, 0));
285        assert_eq!(
286            reg.latest().expect("should succeed in test"),
287            &WorkflowVersion::new(2, 0, 0)
288        );
289    }
290
291    #[test]
292    fn test_registry_add_migration_and_lookup() {
293        let mut reg = WorkflowVersionRegistry::new();
294        let from = WorkflowVersion::new(1, 0, 0);
295        let to = WorkflowVersion::new(1, 1, 0);
296        reg.add_migration(VersionMigration::new(from.clone(), to.clone(), "test"));
297        let m = reg.migrate(&from, &to).expect("should succeed in test");
298        assert_eq!(m.description, "test");
299    }
300
301    #[test]
302    fn test_registry_missing_migration() {
303        let reg = WorkflowVersionRegistry::new();
304        let v = WorkflowVersion::new(1, 0, 0);
305        assert!(reg.migrate(&v, &v).is_none());
306    }
307
308    #[test]
309    fn test_registry_version_count() {
310        let mut reg = WorkflowVersionRegistry::new();
311        reg.register(WorkflowVersion::new(1, 0, 0));
312        reg.register(WorkflowVersion::new(1, 0, 0)); // duplicate — should not increase count
313        assert_eq!(reg.version_count(), 1);
314    }
315
316    #[test]
317    fn test_registry_empty_latest() {
318        let reg = WorkflowVersionRegistry::new();
319        assert!(reg.latest().is_none());
320    }
321}