xerv_core/schema/
version.rs

1//! Schema version model and compatibility rules.
2//!
3//! Provides semantic versioning for schemas with major.minor version numbers.
4//! Breaking changes require major version bumps, non-breaking changes use minor.
5
6use std::cmp::Ordering;
7use std::fmt;
8use std::str::FromStr;
9
10/// Semantic version for schemas.
11///
12/// Uses major.minor versioning:
13/// - Major version changes indicate breaking changes
14/// - Minor version changes indicate backward-compatible changes
15///
16/// # Example
17///
18/// ```ignore
19/// let v1 = SchemaVersion::new(1, 0);
20/// let v1_1 = SchemaVersion::new(1, 1);
21/// let v2 = SchemaVersion::new(2, 0);
22///
23/// assert!(v1.is_compatible_with(&v1_1)); // Same major = compatible
24/// assert!(!v1.is_compatible_with(&v2));  // Different major = breaking
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct SchemaVersion {
28    /// Major version (breaking changes).
29    pub major: u16,
30    /// Minor version (backward-compatible changes).
31    pub minor: u16,
32}
33
34impl SchemaVersion {
35    /// Create a new schema version.
36    pub const fn new(major: u16, minor: u16) -> Self {
37        Self { major, minor }
38    }
39
40    /// Parse a version string (e.g., "v1", "v1.2", "1.0").
41    ///
42    /// Supports formats:
43    /// - "v1" → (1, 0)
44    /// - "v1.2" → (1, 2)
45    /// - "1.0" → (1, 0)
46    /// - "1" → (1, 0)
47    pub fn parse(s: &str) -> Option<Self> {
48        let s = s.strip_prefix('v').unwrap_or(s);
49
50        if let Some((major_str, minor_str)) = s.split_once('.') {
51            let major = major_str.parse().ok()?;
52            let minor = minor_str.parse().ok()?;
53            Some(Self::new(major, minor))
54        } else {
55            let major = s.parse().ok()?;
56            Some(Self::new(major, 0))
57        }
58    }
59
60    /// Check if this version is compatible with another version.
61    ///
62    /// Two versions are compatible if they have the same major version,
63    /// and this version's minor is >= the other's minor.
64    pub fn is_compatible_with(&self, other: &Self) -> bool {
65        self.major == other.major && self.minor >= other.minor
66    }
67
68    /// Check if this version is a direct successor to another.
69    pub fn is_successor_of(&self, other: &Self) -> bool {
70        (self.major == other.major && self.minor == other.minor + 1)
71            || (self.major == other.major + 1 && self.minor == 0)
72    }
73
74    /// Get the next minor version.
75    pub fn next_minor(&self) -> Self {
76        Self::new(self.major, self.minor + 1)
77    }
78
79    /// Get the next major version.
80    pub fn next_major(&self) -> Self {
81        Self::new(self.major + 1, 0)
82    }
83
84    /// Format as a version string (e.g., "v1.0").
85    pub fn to_version_string(&self) -> String {
86        format!("v{}.{}", self.major, self.minor)
87    }
88
89    /// Format as a short version string (e.g., "v1" if minor is 0).
90    pub fn to_short_string(&self) -> String {
91        if self.minor == 0 {
92            format!("v{}", self.major)
93        } else {
94            self.to_version_string()
95        }
96    }
97}
98
99impl Default for SchemaVersion {
100    fn default() -> Self {
101        Self::new(1, 0)
102    }
103}
104
105impl fmt::Display for SchemaVersion {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "v{}.{}", self.major, self.minor)
108    }
109}
110
111impl FromStr for SchemaVersion {
112    type Err = &'static str;
113
114    fn from_str(s: &str) -> Result<Self, Self::Err> {
115        Self::parse(s).ok_or("Invalid version format")
116    }
117}
118
119impl PartialOrd for SchemaVersion {
120    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
121        Some(self.cmp(other))
122    }
123}
124
125impl Ord for SchemaVersion {
126    fn cmp(&self, other: &Self) -> Ordering {
127        match self.major.cmp(&other.major) {
128            Ordering::Equal => self.minor.cmp(&other.minor),
129            ord => ord,
130        }
131    }
132}
133
134/// Version range for compatibility declarations.
135///
136/// Specifies a range of compatible versions, useful for declaring
137/// which schema versions a migration or node supports.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct VersionRange {
140    /// Minimum version (inclusive).
141    pub min: SchemaVersion,
142    /// Maximum version (inclusive, if specified).
143    pub max: Option<SchemaVersion>,
144}
145
146impl VersionRange {
147    /// Create a range starting from a specific version with no upper bound.
148    pub fn from(version: SchemaVersion) -> Self {
149        Self {
150            min: version,
151            max: None,
152        }
153    }
154
155    /// Create a range between two versions (inclusive).
156    pub fn between(min: SchemaVersion, max: SchemaVersion) -> Self {
157        Self {
158            min,
159            max: Some(max),
160        }
161    }
162
163    /// Create an exact version range (single version).
164    pub fn exact(version: SchemaVersion) -> Self {
165        Self {
166            min: version,
167            max: Some(version),
168        }
169    }
170
171    /// Check if a version falls within this range.
172    pub fn contains(&self, version: SchemaVersion) -> bool {
173        if version < self.min {
174            return false;
175        }
176        match self.max {
177            Some(max) => version <= max,
178            None => true,
179        }
180    }
181
182    /// Check if this range overlaps with another.
183    pub fn overlaps(&self, other: &VersionRange) -> bool {
184        // Check if ranges have any intersection
185        let self_max = self.max.unwrap_or(SchemaVersion::new(u16::MAX, u16::MAX));
186        let other_max = other.max.unwrap_or(SchemaVersion::new(u16::MAX, u16::MAX));
187
188        self.min <= other_max && other.min <= self_max
189    }
190}
191
192impl fmt::Display for VersionRange {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self.max {
195            Some(max) if max == self.min => write!(f, "{}", self.min),
196            Some(max) => write!(f, "{}-{}", self.min, max),
197            None => write!(f, "{}+", self.min),
198        }
199    }
200}
201
202/// Type of change between schema versions.
203///
204/// Used to classify schema modifications and determine if a
205/// migration is required.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ChangeKind {
208    /// New optional field added (backward compatible).
209    AddOptionalField,
210    /// New required field added (breaking without default).
211    AddRequiredField,
212    /// Field removed (breaking).
213    RemoveField,
214    /// Field type changed (breaking).
215    ChangeFieldType,
216    /// Field renamed (breaking without migration).
217    RenameField,
218    /// Field marked as deprecated (non-breaking).
219    DeprecateField,
220    /// Field default value changed (non-breaking).
221    ChangeDefault,
222    /// Field made optional (non-breaking).
223    MakeOptional,
224    /// Field made required (breaking).
225    MakeRequired,
226}
227
228impl ChangeKind {
229    /// Check if this change kind is breaking.
230    ///
231    /// Breaking changes require a major version bump and typically
232    /// need a migration function to transform old data.
233    pub fn is_breaking(&self) -> bool {
234        matches!(
235            self,
236            Self::AddRequiredField
237                | Self::RemoveField
238                | Self::ChangeFieldType
239                | Self::RenameField
240                | Self::MakeRequired
241        )
242    }
243
244    /// Get a human-readable description of this change kind.
245    pub fn description(&self) -> &'static str {
246        match self {
247            Self::AddOptionalField => "Added optional field",
248            Self::AddRequiredField => "Added required field",
249            Self::RemoveField => "Removed field",
250            Self::ChangeFieldType => "Changed field type",
251            Self::RenameField => "Renamed field",
252            Self::DeprecateField => "Deprecated field",
253            Self::ChangeDefault => "Changed default value",
254            Self::MakeOptional => "Made field optional",
255            Self::MakeRequired => "Made field required",
256        }
257    }
258}
259
260impl fmt::Display for ChangeKind {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        write!(f, "{}", self.description())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn version_creation() {
272        let v = SchemaVersion::new(1, 2);
273        assert_eq!(v.major, 1);
274        assert_eq!(v.minor, 2);
275    }
276
277    #[test]
278    fn version_parsing() {
279        assert_eq!(SchemaVersion::parse("v1"), Some(SchemaVersion::new(1, 0)));
280        assert_eq!(SchemaVersion::parse("v1.2"), Some(SchemaVersion::new(1, 2)));
281        assert_eq!(SchemaVersion::parse("1.0"), Some(SchemaVersion::new(1, 0)));
282        assert_eq!(SchemaVersion::parse("2"), Some(SchemaVersion::new(2, 0)));
283        assert_eq!(SchemaVersion::parse("invalid"), None);
284        assert_eq!(SchemaVersion::parse("v"), None);
285    }
286
287    #[test]
288    fn version_display() {
289        assert_eq!(SchemaVersion::new(1, 0).to_string(), "v1.0");
290        assert_eq!(SchemaVersion::new(2, 3).to_string(), "v2.3");
291        assert_eq!(SchemaVersion::new(1, 0).to_short_string(), "v1");
292        assert_eq!(SchemaVersion::new(1, 1).to_short_string(), "v1.1");
293    }
294
295    #[test]
296    fn version_compatibility() {
297        let v1_0 = SchemaVersion::new(1, 0);
298        let v1_1 = SchemaVersion::new(1, 1);
299        let v2_0 = SchemaVersion::new(2, 0);
300
301        // Same major, higher minor = compatible
302        assert!(v1_1.is_compatible_with(&v1_0));
303
304        // Same major, lower minor = not compatible
305        assert!(!v1_0.is_compatible_with(&v1_1));
306
307        // Same version = compatible
308        assert!(v1_0.is_compatible_with(&v1_0));
309
310        // Different major = not compatible
311        assert!(!v2_0.is_compatible_with(&v1_0));
312        assert!(!v1_0.is_compatible_with(&v2_0));
313    }
314
315    #[test]
316    fn version_ordering() {
317        let v1_0 = SchemaVersion::new(1, 0);
318        let v1_1 = SchemaVersion::new(1, 1);
319        let v2_0 = SchemaVersion::new(2, 0);
320
321        assert!(v1_0 < v1_1);
322        assert!(v1_1 < v2_0);
323        assert!(v1_0 < v2_0);
324
325        let mut versions = vec![v2_0, v1_0, v1_1];
326        versions.sort();
327        assert_eq!(versions, vec![v1_0, v1_1, v2_0]);
328    }
329
330    #[test]
331    fn version_successor() {
332        let v1_0 = SchemaVersion::new(1, 0);
333        let v1_1 = SchemaVersion::new(1, 1);
334        let v2_0 = SchemaVersion::new(2, 0);
335
336        assert!(v1_1.is_successor_of(&v1_0));
337        assert!(v2_0.is_successor_of(&v1_1));
338        assert!(v2_0.is_successor_of(&v1_0));
339        assert!(!v1_0.is_successor_of(&v1_1));
340    }
341
342    #[test]
343    fn version_range_contains() {
344        let range = VersionRange::between(SchemaVersion::new(1, 0), SchemaVersion::new(1, 5));
345
346        assert!(range.contains(SchemaVersion::new(1, 0)));
347        assert!(range.contains(SchemaVersion::new(1, 3)));
348        assert!(range.contains(SchemaVersion::new(1, 5)));
349        assert!(!range.contains(SchemaVersion::new(0, 9)));
350        assert!(!range.contains(SchemaVersion::new(1, 6)));
351        assert!(!range.contains(SchemaVersion::new(2, 0)));
352    }
353
354    #[test]
355    fn version_range_from() {
356        let range = VersionRange::from(SchemaVersion::new(1, 0));
357
358        assert!(!range.contains(SchemaVersion::new(0, 9)));
359        assert!(range.contains(SchemaVersion::new(1, 0)));
360        assert!(range.contains(SchemaVersion::new(2, 5)));
361        assert!(range.contains(SchemaVersion::new(100, 0)));
362    }
363
364    #[test]
365    fn version_range_exact() {
366        let range = VersionRange::exact(SchemaVersion::new(1, 2));
367
368        assert!(!range.contains(SchemaVersion::new(1, 1)));
369        assert!(range.contains(SchemaVersion::new(1, 2)));
370        assert!(!range.contains(SchemaVersion::new(1, 3)));
371    }
372
373    #[test]
374    fn version_range_overlaps() {
375        let range1 = VersionRange::between(SchemaVersion::new(1, 0), SchemaVersion::new(1, 5));
376        let range2 = VersionRange::between(SchemaVersion::new(1, 3), SchemaVersion::new(2, 0));
377        let range3 = VersionRange::between(SchemaVersion::new(2, 0), SchemaVersion::new(3, 0));
378
379        assert!(range1.overlaps(&range2)); // 1.3-1.5 overlap
380        assert!(range2.overlaps(&range3)); // 2.0 overlap
381        assert!(!range1.overlaps(&range3)); // No overlap
382    }
383
384    #[test]
385    fn change_kind_breaking() {
386        assert!(!ChangeKind::AddOptionalField.is_breaking());
387        assert!(ChangeKind::AddRequiredField.is_breaking());
388        assert!(ChangeKind::RemoveField.is_breaking());
389        assert!(ChangeKind::ChangeFieldType.is_breaking());
390        assert!(ChangeKind::RenameField.is_breaking());
391        assert!(!ChangeKind::DeprecateField.is_breaking());
392        assert!(!ChangeKind::ChangeDefault.is_breaking());
393        assert!(!ChangeKind::MakeOptional.is_breaking());
394        assert!(ChangeKind::MakeRequired.is_breaking());
395    }
396}