python_check_updates/
version.rs

1use serde::{Deserialize, Serialize};
2use std::cmp::Ordering;
3use std::fmt;
4use std::str::FromStr;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum VersionError {
9    #[error("Invalid version string: {0}")]
10    InvalidVersion(String),
11    #[error("Invalid version specifier: {0}")]
12    InvalidSpecifier(String),
13}
14
15/// A parsed semantic version
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Version {
18    pub major: u64,
19    pub minor: u64,
20    pub patch: u64,
21    pub pre_release: Option<String>,
22    pub local: Option<String>,
23    /// Original string representation
24    pub original: String,
25}
26
27impl Version {
28    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
29        Self {
30            major,
31            minor,
32            patch,
33            pre_release: None,
34            local: None,
35            original: format!("{}.{}.{}", major, minor, patch),
36        }
37    }
38
39    /// Check if this is a pre-release version
40    pub fn is_prerelease(&self) -> bool {
41        self.pre_release.is_some()
42    }
43
44    /// Check if this version is in the same major series as another
45    pub fn same_major(&self, other: &Version) -> bool {
46        self.major == other.major
47    }
48
49    /// Check if this version is in the same minor series as another
50    pub fn same_minor(&self, other: &Version) -> bool {
51        self.major == other.major && self.minor == other.minor
52    }
53}
54
55impl FromStr for Version {
56    type Err = VersionError;
57
58    fn from_str(s: &str) -> Result<Self, Self::Err> {
59        let s = s.trim();
60
61        // Handle local version separator (+)
62        let (version_part, local) = if let Some(idx) = s.find('+') {
63            (&s[..idx], Some(s[idx + 1..].to_string()))
64        } else {
65            (s, None)
66        };
67
68        // Handle pre-release separators (-, a, b, rc, alpha, beta, dev, post)
69        let (base_part, pre_release) = parse_prerelease(version_part);
70
71        // Parse the base version (major.minor.patch)
72        let parts: Vec<&str> = base_part.split('.').collect();
73
74        let major = parts
75            .first()
76            .and_then(|s| s.parse().ok())
77            .ok_or_else(|| VersionError::InvalidVersion(s.to_string()))?;
78
79        let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
80
81        let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
82
83        Ok(Version {
84            major,
85            minor,
86            patch,
87            pre_release,
88            local,
89            original: s.to_string(),
90        })
91    }
92}
93
94fn parse_prerelease(s: &str) -> (&str, Option<String>) {
95    // Common pre-release patterns
96    let patterns = [
97        "dev", "post", "alpha", "beta", "rc", "a", "b", "c", "-",
98    ];
99
100    for pattern in patterns {
101        if let Some(idx) = s.to_lowercase().find(pattern) {
102            if idx > 0 {
103                return (&s[..idx], Some(s[idx..].to_string()));
104            }
105        }
106    }
107
108    (s, None)
109}
110
111impl Ord for Version {
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        match self.patch.cmp(&other.patch) {
122            Ordering::Equal => {}
123            ord => return ord,
124        }
125
126        // Pre-release versions are less than release versions
127        match (&self.pre_release, &other.pre_release) {
128            (None, Some(_)) => Ordering::Greater,
129            (Some(_), None) => Ordering::Less,
130            (Some(a), Some(b)) => a.cmp(b),
131            (None, None) => Ordering::Equal,
132        }
133    }
134}
135
136impl PartialOrd for Version {
137    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
138        Some(self.cmp(other))
139    }
140}
141
142impl fmt::Display for Version {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        write!(f, "{}", self.original)
145    }
146}
147
148/// Version specification (constraint)
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum VersionSpec {
151    /// ==1.2.3
152    Pinned(Version),
153    /// >=1.2.3
154    Minimum(Version),
155    /// <=1.2.3
156    Maximum(Version),
157    /// >1.2.3
158    GreaterThan(Version),
159    /// <1.2.3
160    LessThan(Version),
161    /// >=1.2.3,<2.0.0
162    Range { min: Version, max: Version },
163    /// ^1.2.3 (caret - same major)
164    Caret(Version),
165    /// ~1.2.3 (tilde - same minor)
166    Tilde(Version),
167    /// ~=1.2.3 (compatible release)
168    Compatible(Version),
169    /// ==1.2.*
170    Wildcard { prefix: String, pattern: String },
171    /// !=1.2.3
172    NotEqual(Version),
173    /// Complex constraint we store as raw string
174    Complex(String),
175    /// Any version (no constraint or *)
176    Any,
177}
178
179impl VersionSpec {
180    /// Parse a version specifier string
181    pub fn parse(s: &str) -> Result<Self, VersionError> {
182        let s = s.trim();
183
184        if s.is_empty() || s == "*" {
185            return Ok(VersionSpec::Any);
186        }
187
188        // Handle caret notation (poetry/pdm style)
189        if let Some(version_str) = s.strip_prefix('^') {
190            let version = Version::from_str(version_str)?;
191            return Ok(VersionSpec::Caret(version));
192        }
193
194        // Handle tilde notation
195        if let Some(version_str) = s.strip_prefix("~=") {
196            let version = Version::from_str(version_str)?;
197            return Ok(VersionSpec::Compatible(version));
198        }
199        if let Some(version_str) = s.strip_prefix('~') {
200            let version = Version::from_str(version_str)?;
201            return Ok(VersionSpec::Tilde(version));
202        }
203
204        // Handle wildcard
205        if s.contains('*') {
206            if let Some(prefix) = s.strip_prefix("==") {
207                return Ok(VersionSpec::Wildcard {
208                    prefix: prefix.replace(".*", "").replace("*", ""),
209                    pattern: s.to_string(),
210                });
211            }
212            return Ok(VersionSpec::Wildcard {
213                prefix: s.replace(".*", "").replace("*", ""),
214                pattern: s.to_string(),
215            });
216        }
217
218        // Handle range (>=X,<Y)
219        if s.contains(',') {
220            let parts: Vec<&str> = s.split(',').collect();
221            if parts.len() == 2 {
222                let min_part = parts[0].trim();
223                let max_part = parts[1].trim();
224
225                if let (Some(min_str), Some(max_str)) = (
226                    min_part.strip_prefix(">="),
227                    max_part.strip_prefix('<'),
228                ) {
229                    let min = Version::from_str(min_str)?;
230                    let max = Version::from_str(max_str)?;
231                    return Ok(VersionSpec::Range { min, max });
232                }
233            }
234            // Complex constraint
235            return Ok(VersionSpec::Complex(s.to_string()));
236        }
237
238        // Handle simple operators
239        if let Some(version_str) = s.strip_prefix("==") {
240            let version = Version::from_str(version_str)?;
241            return Ok(VersionSpec::Pinned(version));
242        }
243        if let Some(version_str) = s.strip_prefix(">=") {
244            let version = Version::from_str(version_str)?;
245            return Ok(VersionSpec::Minimum(version));
246        }
247        if let Some(version_str) = s.strip_prefix("<=") {
248            let version = Version::from_str(version_str)?;
249            return Ok(VersionSpec::Maximum(version));
250        }
251        if let Some(version_str) = s.strip_prefix("!=") {
252            let version = Version::from_str(version_str)?;
253            return Ok(VersionSpec::NotEqual(version));
254        }
255        if let Some(version_str) = s.strip_prefix('>') {
256            let version = Version::from_str(version_str)?;
257            return Ok(VersionSpec::GreaterThan(version));
258        }
259        if let Some(version_str) = s.strip_prefix('<') {
260            let version = Version::from_str(version_str)?;
261            return Ok(VersionSpec::LessThan(version));
262        }
263
264        // No operator - treat as pinned or complex
265        if let Ok(version) = Version::from_str(s) {
266            return Ok(VersionSpec::Pinned(version));
267        }
268
269        Ok(VersionSpec::Complex(s.to_string()))
270    }
271
272    /// Check if a version satisfies this constraint
273    pub fn satisfies(&self, version: &Version) -> bool {
274        match self {
275            VersionSpec::Any => true,
276            VersionSpec::Pinned(v) => version == v,
277            VersionSpec::Minimum(v) => version >= v,
278            VersionSpec::Maximum(v) => version <= v,
279            VersionSpec::GreaterThan(v) => version > v,
280            VersionSpec::LessThan(v) => version < v,
281            VersionSpec::Range { min, max } => version >= min && version < max,
282            VersionSpec::Caret(v) => version >= v && version.major == v.major,
283            VersionSpec::Tilde(v) => {
284                version >= v && version.major == v.major && version.minor == v.minor
285            }
286            VersionSpec::Compatible(v) => {
287                version >= v && version.major == v.major && version.minor == v.minor
288            }
289            VersionSpec::Wildcard { prefix, .. } => {
290                version.original.starts_with(prefix)
291            }
292            VersionSpec::NotEqual(v) => version != v,
293            VersionSpec::Complex(_) => true, // Can't evaluate complex constraints
294        }
295    }
296
297    /// Get the base version from the spec (for comparison)
298    pub fn base_version(&self) -> Option<&Version> {
299        match self {
300            VersionSpec::Pinned(v)
301            | VersionSpec::Minimum(v)
302            | VersionSpec::Maximum(v)
303            | VersionSpec::GreaterThan(v)
304            | VersionSpec::LessThan(v)
305            | VersionSpec::Caret(v)
306            | VersionSpec::Tilde(v)
307            | VersionSpec::Compatible(v)
308            | VersionSpec::NotEqual(v) => Some(v),
309            VersionSpec::Range { min, .. } => Some(min),
310            VersionSpec::Wildcard { .. } | VersionSpec::Complex(_) | VersionSpec::Any => None,
311        }
312    }
313
314    /// Get the maximum allowed major version (for "in range" calculation)
315    pub fn max_major(&self) -> Option<u64> {
316        match self {
317            VersionSpec::Range { max, .. } => Some(max.major),
318            VersionSpec::Caret(v) => Some(v.major),
319            VersionSpec::LessThan(v) | VersionSpec::Maximum(v) => Some(v.major),
320            // For unbounded specs, we assume same major (semver)
321            VersionSpec::Minimum(v)
322            | VersionSpec::GreaterThan(v)
323            | VersionSpec::Pinned(v)
324            | VersionSpec::Compatible(v)
325            | VersionSpec::Tilde(v) => Some(v.major),
326            VersionSpec::NotEqual(v) => Some(v.major),
327            VersionSpec::Wildcard { prefix, .. } => {
328                prefix.split('.').next().and_then(|s| s.parse().ok())
329            }
330            VersionSpec::Complex(_) | VersionSpec::Any => None,
331        }
332    }
333
334    /// Create a new version spec with updated version but same constraint type
335    pub fn with_version(&self, new_version: &Version) -> VersionSpec {
336        match self {
337            VersionSpec::Pinned(_) => VersionSpec::Pinned(new_version.clone()),
338            VersionSpec::Minimum(_) => VersionSpec::Minimum(new_version.clone()),
339            VersionSpec::Maximum(_) => VersionSpec::Maximum(new_version.clone()),
340            VersionSpec::GreaterThan(_) => VersionSpec::GreaterThan(new_version.clone()),
341            VersionSpec::LessThan(_) => VersionSpec::LessThan(new_version.clone()),
342            VersionSpec::Range { max, .. } => {
343                // If new min would exceed max, update max to next major
344                if new_version >= max {
345                    VersionSpec::Range {
346                        min: new_version.clone(),
347                        max: Version::new(new_version.major + 1, 0, 0),
348                    }
349                } else {
350                    VersionSpec::Range {
351                        min: new_version.clone(),
352                        max: max.clone(),
353                    }
354                }
355            }
356            VersionSpec::Caret(_) => VersionSpec::Caret(new_version.clone()),
357            VersionSpec::Tilde(_) => VersionSpec::Tilde(new_version.clone()),
358            VersionSpec::Compatible(_) => VersionSpec::Compatible(new_version.clone()),
359            VersionSpec::Wildcard { pattern, .. } => {
360                // Update wildcard to new major.minor.*
361                VersionSpec::Wildcard {
362                    prefix: format!("{}.{}", new_version.major, new_version.minor),
363                    pattern: pattern.clone(),
364                }
365            }
366            VersionSpec::NotEqual(_) => VersionSpec::NotEqual(new_version.clone()),
367            VersionSpec::Complex(s) => VersionSpec::Complex(s.clone()),
368            VersionSpec::Any => VersionSpec::Any,
369        }
370    }
371}
372
373impl fmt::Display for VersionSpec {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match self {
376            VersionSpec::Any => write!(f, "*"),
377            VersionSpec::Pinned(v) => write!(f, "=={}", v),
378            VersionSpec::Minimum(v) => write!(f, ">={}", v),
379            VersionSpec::Maximum(v) => write!(f, "<={}", v),
380            VersionSpec::GreaterThan(v) => write!(f, ">{}", v),
381            VersionSpec::LessThan(v) => write!(f, "<{}", v),
382            VersionSpec::Range { min, max } => write!(f, ">={},<{}", min, max),
383            VersionSpec::Caret(v) => write!(f, "^{}", v),
384            VersionSpec::Tilde(v) => write!(f, "~{}", v),
385            VersionSpec::Compatible(v) => write!(f, "~={}", v),
386            VersionSpec::Wildcard { prefix, .. } => write!(f, "=={}.*", prefix),
387            VersionSpec::NotEqual(v) => write!(f, "!={}", v),
388            VersionSpec::Complex(s) => write!(f, "{}", s),
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_parse_version() {
399        let v = Version::from_str("1.2.3").unwrap();
400        assert_eq!(v.major, 1);
401        assert_eq!(v.minor, 2);
402        assert_eq!(v.patch, 3);
403
404        let v = Version::from_str("2.0").unwrap();
405        assert_eq!(v.major, 2);
406        assert_eq!(v.minor, 0);
407        assert_eq!(v.patch, 0);
408    }
409
410    #[test]
411    fn test_version_comparison() {
412        let v1 = Version::from_str("1.2.3").unwrap();
413        let v2 = Version::from_str("1.2.4").unwrap();
414        let v3 = Version::from_str("2.0.0").unwrap();
415
416        assert!(v1 < v2);
417        assert!(v2 < v3);
418        assert!(v1 < v3);
419    }
420
421    #[test]
422    fn test_parse_version_spec() {
423        assert!(matches!(
424            VersionSpec::parse("==1.2.3").unwrap(),
425            VersionSpec::Pinned(_)
426        ));
427        assert!(matches!(
428            VersionSpec::parse(">=1.2.3").unwrap(),
429            VersionSpec::Minimum(_)
430        ));
431        assert!(matches!(
432            VersionSpec::parse("^1.2.3").unwrap(),
433            VersionSpec::Caret(_)
434        ));
435        assert!(matches!(
436            VersionSpec::parse(">=1.0.0,<2.0.0").unwrap(),
437            VersionSpec::Range { .. }
438        ));
439    }
440
441    #[test]
442    fn test_satisfies() {
443        let spec = VersionSpec::parse(">=1.0.0,<2.0.0").unwrap();
444        assert!(spec.satisfies(&Version::from_str("1.5.0").unwrap()));
445        assert!(!spec.satisfies(&Version::from_str("2.0.0").unwrap()));
446        assert!(!spec.satisfies(&Version::from_str("0.9.0").unwrap()));
447    }
448}