rustapi_openapi/versioning/
version.rs

1//! API Version type and parsing
2//!
3//! Provides semantic versioning support for API versions.
4
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::fmt;
8use std::str::FromStr;
9
10/// API version using semantic versioning
11///
12/// Supports formats like:
13/// - `v1`, `v2` (major only)
14/// - `v1.0`, `v1.2` (major.minor)
15/// - `v1.0.0`, `v1.2.3` (major.minor.patch)
16/// - `1`, `1.0`, `1.0.0` (without 'v' prefix)
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct ApiVersion {
19    /// Major version number
20    pub major: u32,
21    /// Minor version number (defaults to 0)
22    pub minor: u32,
23    /// Patch version number (defaults to 0)
24    pub patch: u32,
25}
26
27impl ApiVersion {
28    /// Create a new version
29    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
30        Self {
31            major,
32            minor,
33            patch,
34        }
35    }
36
37    /// Create a version with only major number
38    pub fn major(major: u32) -> Self {
39        Self {
40            major,
41            minor: 0,
42            patch: 0,
43        }
44    }
45
46    /// Create version 1.0.0
47    pub fn v1() -> Self {
48        Self::new(1, 0, 0)
49    }
50
51    /// Create version 2.0.0
52    pub fn v2() -> Self {
53        Self::new(2, 0, 0)
54    }
55
56    /// Create version 3.0.0
57    pub fn v3() -> Self {
58        Self::new(3, 0, 0)
59    }
60
61    /// Check if this version is compatible with another version
62    ///
63    /// Uses semantic versioning compatibility rules:
64    /// - Same major version is considered compatible
65    pub fn is_compatible_with(&self, other: &ApiVersion) -> bool {
66        self.major == other.major
67    }
68
69    /// Check if this version satisfies a version range
70    pub fn satisfies(&self, range: &VersionRange) -> bool {
71        range.contains(self)
72    }
73
74    /// Format as path segment (e.g., "v1", "v1.2")
75    pub fn as_path_segment(&self) -> String {
76        if self.minor == 0 && self.patch == 0 {
77            format!("v{}", self.major)
78        } else if self.patch == 0 {
79            format!("v{}.{}", self.major, self.minor)
80        } else {
81            format!("v{}.{}.{}", self.major, self.minor, self.patch)
82        }
83    }
84}
85
86impl Default for ApiVersion {
87    fn default() -> Self {
88        Self::v1()
89    }
90}
91
92impl fmt::Display for ApiVersion {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
95    }
96}
97
98impl FromStr for ApiVersion {
99    type Err = VersionParseError;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        // Remove optional 'v' prefix
103        let s = s
104            .strip_prefix('v')
105            .or_else(|| s.strip_prefix('V'))
106            .unwrap_or(s);
107
108        let parts: Vec<&str> = s.split('.').collect();
109
110        match parts.len() {
111            1 => {
112                let major = parts[0]
113                    .parse()
114                    .map_err(|_| VersionParseError::InvalidNumber)?;
115                Ok(ApiVersion::major(major))
116            }
117            2 => {
118                let major = parts[0]
119                    .parse()
120                    .map_err(|_| VersionParseError::InvalidNumber)?;
121                let minor = parts[1]
122                    .parse()
123                    .map_err(|_| VersionParseError::InvalidNumber)?;
124                Ok(ApiVersion::new(major, minor, 0))
125            }
126            3 => {
127                let major = parts[0]
128                    .parse()
129                    .map_err(|_| VersionParseError::InvalidNumber)?;
130                let minor = parts[1]
131                    .parse()
132                    .map_err(|_| VersionParseError::InvalidNumber)?;
133                let patch = parts[2]
134                    .parse()
135                    .map_err(|_| VersionParseError::InvalidNumber)?;
136                Ok(ApiVersion::new(major, minor, patch))
137            }
138            _ => Err(VersionParseError::InvalidFormat),
139        }
140    }
141}
142
143impl PartialOrd for ApiVersion {
144    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
145        Some(self.cmp(other))
146    }
147}
148
149impl Ord for ApiVersion {
150    fn cmp(&self, other: &Self) -> Ordering {
151        match self.major.cmp(&other.major) {
152            Ordering::Equal => match self.minor.cmp(&other.minor) {
153                Ordering::Equal => self.patch.cmp(&other.patch),
154                ord => ord,
155            },
156            ord => ord,
157        }
158    }
159}
160
161/// Error type for version parsing
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum VersionParseError {
164    /// Invalid number in version string
165    InvalidNumber,
166    /// Invalid version format
167    InvalidFormat,
168    /// Empty version string
169    Empty,
170}
171
172impl fmt::Display for VersionParseError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            Self::InvalidNumber => write!(f, "invalid number in version"),
176            Self::InvalidFormat => write!(f, "invalid version format"),
177            Self::Empty => write!(f, "empty version string"),
178        }
179    }
180}
181
182impl std::error::Error for VersionParseError {}
183
184/// Version range for matching multiple versions
185#[derive(Debug, Clone)]
186pub struct VersionRange {
187    /// Minimum version (inclusive)
188    pub min: Option<ApiVersion>,
189    /// Maximum version (inclusive)
190    pub max: Option<ApiVersion>,
191    /// Specific excluded versions
192    pub excluded: Vec<ApiVersion>,
193}
194
195impl VersionRange {
196    /// Create a new range with no constraints
197    pub fn any() -> Self {
198        Self {
199            min: None,
200            max: None,
201            excluded: Vec::new(),
202        }
203    }
204
205    /// Create a range for a specific major version
206    pub fn major(version: u32) -> Self {
207        Self {
208            min: Some(ApiVersion::new(version, 0, 0)),
209            max: Some(ApiVersion::new(version, u32::MAX, u32::MAX)),
210            excluded: Vec::new(),
211        }
212    }
213
214    /// Create a range from a minimum version (inclusive)
215    pub fn from(version: ApiVersion) -> Self {
216        Self {
217            min: Some(version),
218            max: None,
219            excluded: Vec::new(),
220        }
221    }
222
223    /// Create a range up to a maximum version (inclusive)
224    pub fn until(version: ApiVersion) -> Self {
225        Self {
226            min: None,
227            max: Some(version),
228            excluded: Vec::new(),
229        }
230    }
231
232    /// Create a range between two versions (inclusive)
233    pub fn between(min: ApiVersion, max: ApiVersion) -> Self {
234        Self {
235            min: Some(min),
236            max: Some(max),
237            excluded: Vec::new(),
238        }
239    }
240
241    /// Create a range for exactly one version
242    pub fn exact(version: ApiVersion) -> Self {
243        Self {
244            min: Some(version),
245            max: Some(version),
246            excluded: Vec::new(),
247        }
248    }
249
250    /// Exclude a specific version from the range
251    pub fn exclude(mut self, version: ApiVersion) -> Self {
252        self.excluded.push(version);
253        self
254    }
255
256    /// Check if a version is within this range
257    pub fn contains(&self, version: &ApiVersion) -> bool {
258        // Check exclusions first
259        if self.excluded.contains(version) {
260            return false;
261        }
262
263        // Check minimum bound
264        if let Some(min) = &self.min {
265            if version < min {
266                return false;
267            }
268        }
269
270        // Check maximum bound
271        if let Some(max) = &self.max {
272            if version > max {
273                return false;
274            }
275        }
276
277        true
278    }
279}
280
281impl Default for VersionRange {
282    fn default() -> Self {
283        Self::any()
284    }
285}
286
287/// Matcher for version selection
288pub trait VersionMatcher: Send + Sync {
289    /// Check if a version matches
290    fn matches(&self, version: &ApiVersion) -> bool;
291
292    /// Get the priority (higher = preferred)
293    fn priority(&self) -> i32 {
294        0
295    }
296}
297
298impl VersionMatcher for ApiVersion {
299    fn matches(&self, version: &ApiVersion) -> bool {
300        self == version
301    }
302}
303
304impl VersionMatcher for VersionRange {
305    fn matches(&self, version: &ApiVersion) -> bool {
306        self.contains(version)
307    }
308}
309
310/// Matcher for major version only
311pub struct MajorVersionMatcher {
312    major: u32,
313}
314
315impl MajorVersionMatcher {
316    /// Create a matcher for a specific major version
317    pub fn new(major: u32) -> Self {
318        Self { major }
319    }
320}
321
322impl VersionMatcher for MajorVersionMatcher {
323    fn matches(&self, version: &ApiVersion) -> bool {
324        version.major == self.major
325    }
326}
327
328/// Matcher that accepts any version
329pub struct AnyVersionMatcher;
330
331impl VersionMatcher for AnyVersionMatcher {
332    fn matches(&self, _version: &ApiVersion) -> bool {
333        true
334    }
335
336    fn priority(&self) -> i32 {
337        -1 // Lower priority than specific matchers
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_version_parsing() {
347        assert_eq!("1".parse::<ApiVersion>().unwrap(), ApiVersion::major(1));
348        assert_eq!("v1".parse::<ApiVersion>().unwrap(), ApiVersion::major(1));
349        assert_eq!(
350            "1.2".parse::<ApiVersion>().unwrap(),
351            ApiVersion::new(1, 2, 0)
352        );
353        assert_eq!(
354            "v1.2.3".parse::<ApiVersion>().unwrap(),
355            ApiVersion::new(1, 2, 3)
356        );
357        assert_eq!("V2".parse::<ApiVersion>().unwrap(), ApiVersion::major(2));
358    }
359
360    #[test]
361    fn test_version_parsing_errors() {
362        assert!("".parse::<ApiVersion>().is_err());
363        assert!("x".parse::<ApiVersion>().is_err());
364        assert!("1.2.3.4".parse::<ApiVersion>().is_err());
365        assert!("v".parse::<ApiVersion>().is_err());
366    }
367
368    #[test]
369    fn test_version_comparison() {
370        assert!(ApiVersion::new(2, 0, 0) > ApiVersion::new(1, 0, 0));
371        assert!(ApiVersion::new(1, 1, 0) > ApiVersion::new(1, 0, 0));
372        assert!(ApiVersion::new(1, 0, 1) > ApiVersion::new(1, 0, 0));
373        assert!(ApiVersion::new(1, 0, 0) == ApiVersion::new(1, 0, 0));
374    }
375
376    #[test]
377    fn test_version_compatibility() {
378        let v1_0 = ApiVersion::new(1, 0, 0);
379        let v1_1 = ApiVersion::new(1, 1, 0);
380        let v2_0 = ApiVersion::new(2, 0, 0);
381
382        assert!(v1_0.is_compatible_with(&v1_1));
383        assert!(v1_1.is_compatible_with(&v1_0));
384        assert!(!v1_0.is_compatible_with(&v2_0));
385    }
386
387    #[test]
388    fn test_version_as_path_segment() {
389        assert_eq!(ApiVersion::major(1).as_path_segment(), "v1");
390        assert_eq!(ApiVersion::new(1, 2, 0).as_path_segment(), "v1.2");
391        assert_eq!(ApiVersion::new(1, 2, 3).as_path_segment(), "v1.2.3");
392    }
393
394    #[test]
395    fn test_version_range_contains() {
396        let range = VersionRange::between(ApiVersion::new(1, 0, 0), ApiVersion::new(2, 0, 0));
397
398        assert!(range.contains(&ApiVersion::new(1, 0, 0)));
399        assert!(range.contains(&ApiVersion::new(1, 5, 0)));
400        assert!(range.contains(&ApiVersion::new(2, 0, 0)));
401        assert!(!range.contains(&ApiVersion::new(0, 9, 0)));
402        assert!(!range.contains(&ApiVersion::new(2, 0, 1)));
403    }
404
405    #[test]
406    fn test_version_range_exclude() {
407        let range = VersionRange::major(1).exclude(ApiVersion::new(1, 5, 0));
408
409        assert!(range.contains(&ApiVersion::new(1, 0, 0)));
410        assert!(range.contains(&ApiVersion::new(1, 4, 0)));
411        assert!(!range.contains(&ApiVersion::new(1, 5, 0)));
412        assert!(range.contains(&ApiVersion::new(1, 6, 0)));
413    }
414
415    #[test]
416    fn test_version_display() {
417        assert_eq!(ApiVersion::new(1, 2, 3).to_string(), "1.2.3");
418    }
419}