Skip to main content

pacha/data/
version.rs

1//! Dataset versioning.
2//!
3//! Uses content-based versioning similar to Git but optimized for large binary files.
4
5use crate::error::{PachaError, Result};
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::fmt;
9use std::str::FromStr;
10
11/// Version for a dataset.
12///
13/// Uses semantic versioning:
14/// - MAJOR: Schema breaking change
15/// - MINOR: New data added (backward compatible)
16/// - PATCH: Data corrections, documentation updates
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct DatasetVersion {
19    /// Major version (schema changes).
20    pub major: u32,
21    /// Minor version (new data).
22    pub minor: u32,
23    /// Patch version (corrections).
24    pub patch: u32,
25}
26
27impl DatasetVersion {
28    /// Create a new version.
29    #[must_use]
30    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
31        Self { major, minor, patch }
32    }
33
34    /// Create version 1.0.0.
35    #[must_use]
36    pub fn initial() -> Self {
37        Self::new(1, 0, 0)
38    }
39
40    /// Increment major version (resets minor and patch).
41    #[must_use]
42    pub fn bump_major(&self) -> Self {
43        Self::new(self.major + 1, 0, 0)
44    }
45
46    /// Increment minor version (resets patch).
47    #[must_use]
48    pub fn bump_minor(&self) -> Self {
49        Self::new(self.major, self.minor + 1, 0)
50    }
51
52    /// Increment patch version.
53    #[must_use]
54    pub fn bump_patch(&self) -> Self {
55        Self::new(self.major, self.minor, self.patch + 1)
56    }
57
58    /// Parse a version string.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the string is not a valid version.
63    pub fn parse(s: &str) -> Result<Self> {
64        s.parse()
65    }
66}
67
68impl Default for DatasetVersion {
69    fn default() -> Self {
70        Self::initial()
71    }
72}
73
74impl fmt::Display for DatasetVersion {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
77    }
78}
79
80impl FromStr for DatasetVersion {
81    type Err = PachaError;
82
83    fn from_str(s: &str) -> Result<Self> {
84        let parts: Vec<&str> = s.split('.').collect();
85        if parts.len() != 3 {
86            return Err(PachaError::InvalidVersion(format!(
87                "expected MAJOR.MINOR.PATCH, got '{s}'"
88            )));
89        }
90
91        let major = parts[0]
92            .parse::<u32>()
93            .map_err(|_| PachaError::InvalidVersion(format!("invalid major version in '{s}'")))?;
94        let minor = parts[1]
95            .parse::<u32>()
96            .map_err(|_| PachaError::InvalidVersion(format!("invalid minor version in '{s}'")))?;
97        let patch = parts[2]
98            .parse::<u32>()
99            .map_err(|_| PachaError::InvalidVersion(format!("invalid patch version in '{s}'")))?;
100
101        Ok(Self { major, minor, patch })
102    }
103}
104
105impl PartialOrd for DatasetVersion {
106    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
107        Some(self.cmp(other))
108    }
109}
110
111impl Ord for DatasetVersion {
112    fn cmp(&self, other: &Self) -> Ordering {
113        match self.major.cmp(&other.major) {
114            Ordering::Equal => {}
115            ord => return ord,
116        }
117        match self.minor.cmp(&other.minor) {
118            Ordering::Equal => {}
119            ord => return ord,
120        }
121        self.patch.cmp(&other.patch)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use proptest::prelude::*;
129
130    #[test]
131    fn test_version_new() {
132        let v = DatasetVersion::new(1, 2, 3);
133        assert_eq!(v.major, 1);
134        assert_eq!(v.minor, 2);
135        assert_eq!(v.patch, 3);
136    }
137
138    #[test]
139    fn test_version_display() {
140        assert_eq!(DatasetVersion::new(1, 2, 3).to_string(), "1.2.3");
141    }
142
143    #[test]
144    fn test_version_parse() {
145        assert_eq!("1.2.3".parse::<DatasetVersion>().unwrap(), DatasetVersion::new(1, 2, 3));
146    }
147
148    #[test]
149    fn test_version_parse_errors() {
150        assert!("1.2".parse::<DatasetVersion>().is_err());
151        assert!("a.b.c".parse::<DatasetVersion>().is_err());
152    }
153
154    #[test]
155    fn test_version_bump() {
156        let v = DatasetVersion::new(1, 2, 3);
157        assert_eq!(v.bump_major(), DatasetVersion::new(2, 0, 0));
158        assert_eq!(v.bump_minor(), DatasetVersion::new(1, 3, 0));
159        assert_eq!(v.bump_patch(), DatasetVersion::new(1, 2, 4));
160    }
161
162    #[test]
163    fn test_version_ordering() {
164        let v100 = DatasetVersion::new(1, 0, 0);
165        let v110 = DatasetVersion::new(1, 1, 0);
166        let v200 = DatasetVersion::new(2, 0, 0);
167
168        assert!(v100 < v110);
169        assert!(v110 < v200);
170    }
171
172    proptest! {
173        #[test]
174        fn prop_version_roundtrip(major: u32, minor: u32, patch: u32) {
175            let v = DatasetVersion::new(major, minor, patch);
176            let s = v.to_string();
177            let parsed: DatasetVersion = s.parse().unwrap();
178            prop_assert_eq!(v, parsed);
179        }
180    }
181}