Skip to main content

check_updates_core/
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, Serialize, Deserialize)]
17pub struct Version {
18    pub major: u64,
19    pub minor: u64,
20    pub patch: u64,
21    pub pre_release: Option<String>,
22    /// Local version segment (Python) or build metadata (Cargo)
23    pub local: Option<String>,
24    /// Original string representation
25    pub original: String,
26}
27
28impl PartialEq for Version {
29    fn eq(&self, other: &Self) -> bool {
30        self.major == other.major
31            && self.minor == other.minor
32            && self.patch == other.patch
33            && self.pre_release == other.pre_release
34    }
35}
36
37impl Eq for Version {}
38
39impl Version {
40    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
41        Self {
42            major,
43            minor,
44            patch,
45            pre_release: None,
46            local: None,
47            original: format!("{major}.{minor}.{patch}"),
48        }
49    }
50
51    /// Check if this is a pre-release version
52    pub fn is_prerelease(&self) -> bool {
53        self.pre_release.is_some()
54    }
55
56    /// Check if this version is in the same major series as another
57    pub fn same_major(&self, other: &Version) -> bool {
58        self.major == other.major
59    }
60
61    /// Check if this version is in the same minor series as another
62    pub fn same_minor(&self, other: &Version) -> bool {
63        self.major == other.major && self.minor == other.minor
64    }
65}
66
67impl FromStr for Version {
68    type Err = VersionError;
69
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        let s = s.trim();
72
73        // Handle local version separator (+)
74        let (version_part, local) = if let Some(idx) = s.find('+') {
75            (&s[..idx], Some(s[idx + 1..].to_string()))
76        } else {
77            (s, None)
78        };
79
80        // Handle pre-release separators (-, a, b, rc, alpha, beta, dev, post)
81        let (base_part, pre_release) = parse_prerelease(version_part);
82
83        // Parse the base version (major.minor.patch)
84        let parts: Vec<&str> = base_part.split('.').collect();
85
86        let major = parts
87            .first()
88            .and_then(|s| s.parse().ok())
89            .ok_or_else(|| VersionError::InvalidVersion(s.to_string()))?;
90
91        let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
92
93        let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
94
95        Ok(Version {
96            major,
97            minor,
98            patch,
99            pre_release,
100            local,
101            original: s.to_string(),
102        })
103    }
104}
105
106fn parse_prerelease(s: &str) -> (&str, Option<String>) {
107    // Common pre-release patterns
108    let patterns = [
109        "dev", "post", "alpha", "beta", "rc", "a", "b", "c", "-",
110    ];
111
112    for pattern in patterns {
113        if let Some(idx) = s.to_lowercase().find(pattern)
114            && idx > 0 {
115                return (&s[..idx], Some(s[idx..].to_string()));
116            }
117    }
118
119    (s, None)
120}
121
122impl Ord for Version {
123    fn cmp(&self, other: &Self) -> Ordering {
124        match self.major.cmp(&other.major) {
125            Ordering::Equal => {}
126            ord => return ord,
127        }
128        match self.minor.cmp(&other.minor) {
129            Ordering::Equal => {}
130            ord => return ord,
131        }
132        match self.patch.cmp(&other.patch) {
133            Ordering::Equal => {}
134            ord => return ord,
135        }
136
137        // Pre-release versions are less than release versions
138        match (&self.pre_release, &other.pre_release) {
139            (None, Some(_)) => Ordering::Greater,
140            (Some(_), None) => Ordering::Less,
141            (Some(a), Some(b)) => a.cmp(b),
142            (None, None) => Ordering::Equal,
143        }
144    }
145}
146
147impl PartialOrd for Version {
148    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
149        Some(self.cmp(other))
150    }
151}
152
153impl fmt::Display for Version {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "{}", self.original)
156    }
157}
158
159/// Version specification (constraint)
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum VersionSpec {
162    /// ==1.2.3
163    Pinned(Version),
164    /// >=1.2.3
165    Minimum(Version),
166    /// <=1.2.3
167    Maximum(Version),
168    /// >1.2.3
169    GreaterThan(Version),
170    /// <1.2.3
171    LessThan(Version),
172    /// >=1.2.3,<2.0.0
173    Range { min: Version, max: Version },
174    /// ^1.2.3 (caret - same major)
175    Caret(Version),
176    /// ~1.2.3 (tilde - same minor)
177    Tilde(Version),
178    /// ~=1.2.3 (compatible release - Python)
179    Compatible(Version),
180    /// ==1.2.*
181    Wildcard { prefix: String, pattern: String },
182    /// !=1.2.3
183    NotEqual(Version),
184    /// Complex constraint we store as raw string
185    Complex(String),
186    /// Any version (no constraint or *)
187    Any,
188}
189
190impl VersionSpec {
191    /// Parse a version specifier string
192    pub fn parse(s: &str) -> Result<Self, VersionError> {
193        let s = s.trim();
194
195        if s.is_empty() || s == "*" {
196            return Ok(VersionSpec::Any);
197        }
198
199        // Handle caret notation (poetry/pdm style)
200        if let Some(version_str) = s.strip_prefix('^') {
201            let version = Version::from_str(version_str)?;
202            return Ok(VersionSpec::Caret(version));
203        }
204
205        // Handle tilde notation
206        if let Some(version_str) = s.strip_prefix("~=") {
207            let version = Version::from_str(version_str)?;
208            return Ok(VersionSpec::Compatible(version));
209        }
210        if let Some(version_str) = s.strip_prefix('~') {
211            let version = Version::from_str(version_str)?;
212            return Ok(VersionSpec::Tilde(version));
213        }
214
215        // Handle wildcard
216        if s.contains('*') {
217            if let Some(prefix) = s.strip_prefix("==") {
218                return Ok(VersionSpec::Wildcard {
219                    prefix: prefix.replace(".*", "").replace("*", ""),
220                    pattern: s.to_string(),
221                });
222            }
223            return Ok(VersionSpec::Wildcard {
224                prefix: s.replace(".*", "").replace("*", ""),
225                pattern: s.to_string(),
226            });
227        }
228
229        // Handle range (>=X,<Y)
230        if s.contains(',') {
231            let parts: Vec<&str> = s.split(',').collect();
232            if parts.len() == 2 {
233                let min_part = parts[0].trim();
234                let max_part = parts[1].trim();
235
236                if let (Some(min_str), Some(max_str)) = (
237                    min_part.strip_prefix(">="),
238                    max_part.strip_prefix('<'),
239                ) {
240                    let min = Version::from_str(min_str)?;
241                    let max = Version::from_str(max_str)?;
242                    return Ok(VersionSpec::Range { min, max });
243                }
244            }
245            // Complex constraint
246            return Ok(VersionSpec::Complex(s.to_string()));
247        }
248
249        // Handle simple operators
250        if let Some(version_str) = s.strip_prefix("==") {
251            let version = Version::from_str(version_str)?;
252            return Ok(VersionSpec::Pinned(version));
253        }
254        if let Some(version_str) = s.strip_prefix(">=") {
255            let version = Version::from_str(version_str)?;
256            return Ok(VersionSpec::Minimum(version));
257        }
258        if let Some(version_str) = s.strip_prefix("<=") {
259            let version = Version::from_str(version_str)?;
260            return Ok(VersionSpec::Maximum(version));
261        }
262        if let Some(version_str) = s.strip_prefix("!=") {
263            let version = Version::from_str(version_str)?;
264            return Ok(VersionSpec::NotEqual(version));
265        }
266        if let Some(version_str) = s.strip_prefix('>') {
267            let version = Version::from_str(version_str)?;
268            return Ok(VersionSpec::GreaterThan(version));
269        }
270        if let Some(version_str) = s.strip_prefix('<') {
271            let version = Version::from_str(version_str)?;
272            return Ok(VersionSpec::LessThan(version));
273        }
274
275        // No operator - treat as pinned or complex
276        if let Ok(version) = Version::from_str(s) {
277            return Ok(VersionSpec::Pinned(version));
278        }
279
280        Ok(VersionSpec::Complex(s.to_string()))
281    }
282
283    /// Check if a version satisfies this constraint
284    pub fn satisfies(&self, version: &Version) -> bool {
285        match self {
286            VersionSpec::Any => true,
287            VersionSpec::Pinned(v) => version == v,
288            VersionSpec::Minimum(v) => version >= v,
289            VersionSpec::Maximum(v) => version <= v,
290            VersionSpec::GreaterThan(v) => version > v,
291            VersionSpec::LessThan(v) => version < v,
292            VersionSpec::Range { min, max } => version >= min && version < max,
293            VersionSpec::Caret(v) => {
294                // Caret: ^1.2.3 means >=1.2.3 <2.0.0
295                // But for 0.x: ^0.1.2 means >=0.1.2 <0.2.0
296                // And for 0.0.x: ^0.0.3 means =0.0.3
297                if version < v {
298                    return false;
299                }
300                if v.major == 0 {
301                    if v.minor == 0 {
302                        // ^0.0.z means =0.0.z
303                        version.major == 0 && version.minor == 0 && version.patch == v.patch
304                    } else {
305                        // ^0.y.z means >=0.y.z <0.(y+1).0
306                        version.major == 0 && version.minor == v.minor
307                    }
308                } else {
309                    // ^x.y.z means >=x.y.z <(x+1).0.0
310                    version.major == v.major
311                }
312            }
313            VersionSpec::Tilde(v) => {
314                version >= v && version.major == v.major && version.minor == v.minor
315            }
316            VersionSpec::Compatible(v) => {
317                // PEP 440: ~=X.Y means >=X.Y, <(X+1).0.0 (lock major only)
318                //          ~=X.Y.Z means >=X.Y.Z, <X.(Y+1).0 (lock major+minor)
319                let dot_count = v.original.chars().filter(|c| *c == '.').count();
320                if dot_count < 2 {
321                    // ~=X.Y form: only lock on major
322                    version >= v && version.major == v.major
323                } else {
324                    // ~=X.Y.Z form: lock on major+minor
325                    version >= v && version.major == v.major && version.minor == v.minor
326                }
327            }
328            VersionSpec::Wildcard { prefix, .. } => {
329                // Must match prefix followed by a dot (or end), so 1.2.* doesn't match 1.20.x
330                version.original.starts_with(&format!("{prefix}."))
331                    || version.original == *prefix
332            }
333            VersionSpec::NotEqual(v) => version != v,
334            VersionSpec::Complex(_) => false, // Can't evaluate complex constraints; don't claim in-range
335        }
336    }
337
338    /// Get the base version from the spec (for comparison)
339    pub fn base_version(&self) -> Option<&Version> {
340        match self {
341            VersionSpec::Pinned(v)
342            | VersionSpec::Minimum(v)
343            | VersionSpec::Maximum(v)
344            | VersionSpec::GreaterThan(v)
345            | VersionSpec::LessThan(v)
346            | VersionSpec::Caret(v)
347            | VersionSpec::Tilde(v)
348            | VersionSpec::Compatible(v)
349            | VersionSpec::NotEqual(v) => Some(v),
350            VersionSpec::Range { min, .. } => Some(min),
351            VersionSpec::Wildcard { .. } | VersionSpec::Complex(_) | VersionSpec::Any => None,
352        }
353    }
354
355    /// Get the maximum allowed major version (for "in range" calculation)
356    pub fn max_major(&self) -> Option<u64> {
357        match self {
358            VersionSpec::Range { max, .. } => Some(max.major),
359            VersionSpec::Caret(v) => Some(v.major),
360            VersionSpec::LessThan(v) | VersionSpec::Maximum(v) => Some(v.major),
361            // For unbounded specs, we assume same major (semver)
362            VersionSpec::Minimum(v)
363            | VersionSpec::GreaterThan(v)
364            | VersionSpec::Pinned(v)
365            | VersionSpec::Compatible(v)
366            | VersionSpec::Tilde(v) => Some(v.major),
367            VersionSpec::NotEqual(v) => Some(v.major),
368            VersionSpec::Wildcard { prefix, .. } => {
369                prefix.split('.').next().and_then(|s| s.parse().ok())
370            }
371            VersionSpec::Complex(_) | VersionSpec::Any => None,
372        }
373    }
374
375    /// Get the version string without operators (for Cargo.toml format)
376    /// Returns just "1.0.0" instead of "==1.0.0"
377    pub fn version_string(&self) -> Option<String> {
378        match self {
379            VersionSpec::Pinned(v)
380            | VersionSpec::Minimum(v)
381            | VersionSpec::Maximum(v)
382            | VersionSpec::GreaterThan(v)
383            | VersionSpec::LessThan(v)
384            | VersionSpec::Caret(v)
385            | VersionSpec::Tilde(v)
386            | VersionSpec::Compatible(v)
387            | VersionSpec::NotEqual(v) => Some(v.to_string()),
388            VersionSpec::Range { min, .. } => Some(min.to_string()),
389            VersionSpec::Wildcard { prefix, .. } => Some(format!("{prefix}.*")),
390            VersionSpec::Complex(s) => Some(s.clone()),
391            VersionSpec::Any => None,
392        }
393    }
394
395    /// Serialize to Cargo.toml requirement syntax.
396    /// Cargo conventions: bare version = caret, `=` for exact pin, `~` for tilde, etc.
397    pub fn to_cargo_string(&self) -> Option<String> {
398        match self {
399            VersionSpec::Caret(v) => Some(v.to_string()), // bare = caret in Cargo
400            VersionSpec::Tilde(v) => Some(format!("~{v}")),
401            VersionSpec::Pinned(v) => Some(format!("={v}")), // Cargo uses single =
402            VersionSpec::Minimum(v) => Some(format!(">={v}")),
403            VersionSpec::Maximum(v) => Some(format!("<={v}")),
404            VersionSpec::GreaterThan(v) => Some(format!(">{v}")),
405            VersionSpec::LessThan(v) => Some(format!("<{v}")),
406            VersionSpec::Range { min, max } => Some(format!(">={min}, <{max}")),
407            VersionSpec::Wildcard { prefix, .. } => Some(format!("{prefix}.*")),
408            VersionSpec::NotEqual(v) => Some(format!("!={v}")),
409            VersionSpec::Compatible(v) => Some(v.to_string()), // not a Cargo concept, treat as bare
410            VersionSpec::Complex(s) => Some(s.clone()),
411            VersionSpec::Any => Some("*".to_string()),
412        }
413    }
414
415    /// Returns true if this spec can be safely rewritten by an updater
416    pub fn is_rewritable(&self) -> bool {
417        !matches!(self, VersionSpec::Complex(_) | VersionSpec::Any)
418    }
419
420    /// Create a new version spec with updated version but same constraint type
421    pub fn with_version(&self, new_version: &Version) -> VersionSpec {
422        match self {
423            VersionSpec::Pinned(_) => VersionSpec::Pinned(new_version.clone()),
424            VersionSpec::Minimum(_) => VersionSpec::Minimum(new_version.clone()),
425            VersionSpec::Maximum(_) => VersionSpec::Maximum(new_version.clone()),
426            VersionSpec::GreaterThan(_) => VersionSpec::GreaterThan(new_version.clone()),
427            VersionSpec::LessThan(_) => VersionSpec::LessThan(new_version.clone()),
428            VersionSpec::Range { max, .. } => {
429                // If new min would exceed max, update max to next major
430                if new_version >= max {
431                    VersionSpec::Range {
432                        min: new_version.clone(),
433                        max: Version::new(new_version.major + 1, 0, 0),
434                    }
435                } else {
436                    VersionSpec::Range {
437                        min: new_version.clone(),
438                        max: max.clone(),
439                    }
440                }
441            }
442            VersionSpec::Caret(_) => VersionSpec::Caret(new_version.clone()),
443            VersionSpec::Tilde(_) => VersionSpec::Tilde(new_version.clone()),
444            VersionSpec::Compatible(_) => VersionSpec::Compatible(new_version.clone()),
445            VersionSpec::Wildcard { prefix, pattern } => {
446                // Preserve the original wildcard precision:
447                // "1.*" (1 segment) → "2.*", "1.2.*" (2 segments) → "1.3.*"
448                let segments = prefix.split('.').count();
449                let new_prefix = match segments {
450                    0 | 1 => format!("{}", new_version.major),
451                    _ => format!("{}.{}", new_version.major, new_version.minor),
452                };
453                VersionSpec::Wildcard {
454                    prefix: new_prefix,
455                    pattern: pattern.clone(),
456                }
457            }
458            VersionSpec::NotEqual(_) => VersionSpec::NotEqual(new_version.clone()),
459            VersionSpec::Complex(s) => VersionSpec::Complex(s.clone()),
460            VersionSpec::Any => VersionSpec::Any,
461        }
462    }
463}
464
465impl fmt::Display for VersionSpec {
466    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467        match self {
468            VersionSpec::Any => write!(f, "*"),
469            VersionSpec::Pinned(v) => write!(f, "=={v}"),
470            VersionSpec::Minimum(v) => write!(f, ">={v}"),
471            VersionSpec::Maximum(v) => write!(f, "<={v}"),
472            VersionSpec::GreaterThan(v) => write!(f, ">{v}"),
473            VersionSpec::LessThan(v) => write!(f, "<{v}"),
474            VersionSpec::Range { min, max } => write!(f, ">={min},<{max}"),
475            VersionSpec::Caret(v) => write!(f, "^{v}"),
476            VersionSpec::Tilde(v) => write!(f, "~{v}"),
477            VersionSpec::Compatible(v) => write!(f, "~={v}"),
478            VersionSpec::Wildcard { prefix, .. } => write!(f, "=={prefix}.*"),
479            VersionSpec::NotEqual(v) => write!(f, "!={v}"),
480            VersionSpec::Complex(s) => write!(f, "{s}"),
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_parse_version() {
491        let v = Version::from_str("1.2.3").unwrap();
492        assert_eq!(v.major, 1);
493        assert_eq!(v.minor, 2);
494        assert_eq!(v.patch, 3);
495
496        let v = Version::from_str("2.0").unwrap();
497        assert_eq!(v.major, 2);
498        assert_eq!(v.minor, 0);
499        assert_eq!(v.patch, 0);
500    }
501
502    #[test]
503    fn test_version_comparison() {
504        let v1 = Version::from_str("1.2.3").unwrap();
505        let v2 = Version::from_str("1.2.4").unwrap();
506        let v3 = Version::from_str("2.0.0").unwrap();
507
508        assert!(v1 < v2);
509        assert!(v2 < v3);
510        assert!(v1 < v3);
511    }
512
513    #[test]
514    fn test_parse_version_spec() {
515        assert!(matches!(
516            VersionSpec::parse("==1.2.3").unwrap(),
517            VersionSpec::Pinned(_)
518        ));
519        assert!(matches!(
520            VersionSpec::parse(">=1.2.3").unwrap(),
521            VersionSpec::Minimum(_)
522        ));
523        assert!(matches!(
524            VersionSpec::parse("^1.2.3").unwrap(),
525            VersionSpec::Caret(_)
526        ));
527        assert!(matches!(
528            VersionSpec::parse(">=1.0.0,<2.0.0").unwrap(),
529            VersionSpec::Range { .. }
530        ));
531    }
532
533    #[test]
534    fn test_satisfies() {
535        let spec = VersionSpec::parse(">=1.0.0,<2.0.0").unwrap();
536        assert!(spec.satisfies(&Version::from_str("1.5.0").unwrap()));
537        assert!(!spec.satisfies(&Version::from_str("2.0.0").unwrap()));
538        assert!(!spec.satisfies(&Version::from_str("0.9.0").unwrap()));
539    }
540}