Skip to main content

hx_core/
version.rs

1//! Version parsing and comparison.
2
3use serde::{Deserialize, Serialize};
4use std::cmp::Ordering;
5use std::fmt;
6use std::str::FromStr;
7
8/// A semantic version.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct Version {
11    pub major: u32,
12    pub minor: u32,
13    pub patch: u32,
14    pub pre: Option<String>,
15}
16
17impl Version {
18    /// Create a new version.
19    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
20        Self {
21            major,
22            minor,
23            patch,
24            pre: None,
25        }
26    }
27
28    /// Check if this version is compatible with another (same major.minor).
29    pub fn is_compatible_with(&self, other: &Version) -> bool {
30        self.major == other.major && self.minor == other.minor
31    }
32
33    /// Parse version from tool output like "ghc 9.8.2" or "cabal 3.12.1.0".
34    pub fn parse_from_output(output: &str) -> Option<Self> {
35        // Try to find a version pattern in the output
36        let version_pattern = regex_lite::Regex::new(r"(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?").ok()?;
37
38        if let Some(captures) = version_pattern.captures(output) {
39            let major: u32 = captures.get(1)?.as_str().parse().ok()?;
40            let minor: u32 = captures.get(2)?.as_str().parse().ok()?;
41            let patch: u32 = captures.get(3)?.as_str().parse().ok()?;
42            // If there's a 4th component (like cabal 3.12.1.0), include it in patch
43            if let Some(fourth) = captures.get(4) {
44                let fourth_num: u32 = fourth.as_str().parse().ok()?;
45                // For tools like cabal with 4-part versions, combine last two
46                return Some(Self::new(major, minor, patch * 100 + fourth_num));
47            }
48            return Some(Self::new(major, minor, patch));
49        }
50
51        None
52    }
53}
54
55impl fmt::Display for Version {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
58        if let Some(ref pre) = self.pre {
59            write!(f, "-{}", pre)?;
60        }
61        Ok(())
62    }
63}
64
65impl FromStr for Version {
66    type Err = VersionParseError;
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        let s = s.trim();
70
71        // Split off pre-release suffix
72        let (version_part, pre) = if let Some(idx) = s.find('-') {
73            (&s[..idx], Some(s[idx + 1..].to_string()))
74        } else {
75            (s, None)
76        };
77
78        let parts: Vec<&str> = version_part.split('.').collect();
79        if parts.len() < 2 {
80            return Err(VersionParseError::InvalidFormat(s.to_string()));
81        }
82
83        let major = parts[0]
84            .parse()
85            .map_err(|_| VersionParseError::InvalidNumber(parts[0].to_string()))?;
86        let minor = parts[1]
87            .parse()
88            .map_err(|_| VersionParseError::InvalidNumber(parts[1].to_string()))?;
89        let patch = if parts.len() > 2 {
90            // Handle 4-part versions like 3.12.1.0
91            if parts.len() == 4 {
92                let p: u32 = parts[2]
93                    .parse()
94                    .map_err(|_| VersionParseError::InvalidNumber(parts[2].to_string()))?;
95                let q: u32 = parts[3]
96                    .parse()
97                    .map_err(|_| VersionParseError::InvalidNumber(parts[3].to_string()))?;
98                p * 100 + q
99            } else {
100                parts[2]
101                    .parse()
102                    .map_err(|_| VersionParseError::InvalidNumber(parts[2].to_string()))?
103            }
104        } else {
105            0
106        };
107
108        Ok(Version {
109            major,
110            minor,
111            patch,
112            pre,
113        })
114    }
115}
116
117impl PartialOrd for Version {
118    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
119        Some(self.cmp(other))
120    }
121}
122
123impl Ord for Version {
124    fn cmp(&self, other: &Self) -> Ordering {
125        match self.major.cmp(&other.major) {
126            Ordering::Equal => {}
127            ord => return ord,
128        }
129        match self.minor.cmp(&other.minor) {
130            Ordering::Equal => {}
131            ord => return ord,
132        }
133        match self.patch.cmp(&other.patch) {
134            Ordering::Equal => {}
135            ord => return ord,
136        }
137        // Pre-release versions are less than release versions
138        match (&self.pre, &other.pre) {
139            (None, None) => Ordering::Equal,
140            (Some(_), None) => Ordering::Less,
141            (None, Some(_)) => Ordering::Greater,
142            (Some(a), Some(b)) => a.cmp(b),
143        }
144    }
145}
146
147/// Error parsing a version string.
148#[derive(Debug, thiserror::Error)]
149pub enum VersionParseError {
150    #[error("invalid version format: {0}")]
151    InvalidFormat(String),
152    #[error("invalid version number: {0}")]
153    InvalidNumber(String),
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_parse_version() {
162        assert_eq!("9.8.2".parse::<Version>().unwrap(), Version::new(9, 8, 2));
163        assert_eq!("1.0.0".parse::<Version>().unwrap(), Version::new(1, 0, 0));
164    }
165
166    #[test]
167    fn test_parse_from_output() {
168        assert_eq!(
169            Version::parse_from_output(
170                "The Glorious Glasgow Haskell Compilation System, version 9.8.2"
171            ),
172            Some(Version::new(9, 8, 2))
173        );
174        assert_eq!(
175            Version::parse_from_output("cabal-install version 3.12.1.0"),
176            Some(Version::new(3, 12, 100))
177        );
178    }
179
180    #[test]
181    fn test_version_ordering() {
182        assert!(Version::new(9, 8, 2) > Version::new(9, 6, 4));
183        assert!(Version::new(9, 8, 2) > Version::new(9, 8, 1));
184        assert!(Version::new(10, 0, 0) > Version::new(9, 99, 99));
185    }
186}