vx_version/
manager.rs

1//! Version management utilities
2
3use crate::{GitHubVersionFetcher, NodeVersionFetcher, Result, VersionError, VersionFetcher};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::process::Command;
7use which::which;
8
9/// Semantic version representation
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
11pub struct Version {
12    pub major: u32,
13    pub minor: u32,
14    pub patch: u32,
15    pub pre: Option<String>,
16}
17
18impl Version {
19    /// Parse a version string into a Version struct
20    pub fn parse(version_str: &str) -> Result<Self> {
21        let version_str = version_str.trim_start_matches('v');
22
23        // First split by dash to separate main version from prerelease
24        let (main_version, pre) = if let Some(dash_pos) = version_str.find('-') {
25            let main = &version_str[..dash_pos];
26            let pre_part = &version_str[dash_pos + 1..];
27            (main, Some(pre_part.to_string()))
28        } else {
29            (version_str, None)
30        };
31
32        // Now split the main version by dots
33        let parts: Vec<&str> = main_version.split('.').collect();
34
35        if parts.len() < 3 {
36            return Err(VersionError::InvalidVersion {
37                version: version_str.to_string(),
38                reason: "Version must have at least major.minor.patch".to_string(),
39            });
40        }
41
42        let major = parts[0].parse().map_err(|_| VersionError::InvalidVersion {
43            version: version_str.to_string(),
44            reason: format!("Invalid major version: {}", parts[0]),
45        })?;
46
47        let minor = parts[1].parse().map_err(|_| VersionError::InvalidVersion {
48            version: version_str.to_string(),
49            reason: format!("Invalid minor version: {}", parts[1]),
50        })?;
51
52        let patch = parts[2].parse().map_err(|_| VersionError::InvalidVersion {
53            version: version_str.to_string(),
54            reason: format!("Invalid patch version: {}", parts[2]),
55        })?;
56
57        Ok(Self {
58            major,
59            minor,
60            patch,
61            pre,
62        })
63    }
64
65    /// Convert version to string representation
66    pub fn as_string(&self) -> String {
67        match &self.pre {
68            Some(pre) => format!("{}.{}.{}-{}", self.major, self.minor, self.patch, pre),
69            None => format!("{}.{}.{}", self.major, self.minor, self.patch),
70        }
71    }
72
73    /// Check if this is a prerelease version
74    pub fn is_prerelease(&self) -> bool {
75        self.pre.is_some()
76    }
77}
78
79impl fmt::Display for Version {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.as_string())
82    }
83}
84
85/// Version manager for handling tool versions
86pub struct VersionManager;
87
88impl VersionManager {
89    /// Check if a tool is installed and get its version
90    pub fn get_installed_version(tool_name: &str) -> Result<Option<Version>> {
91        // Check if tool is available in PATH
92        if which(tool_name).is_err() {
93            return Ok(None);
94        }
95
96        // Try to get version
97        let output = Command::new(tool_name)
98            .arg("--version")
99            .output()
100            .map_err(|e| VersionError::CommandError {
101                command: format!("{} --version", tool_name),
102                source: e,
103            })?;
104
105        if !output.status.success() {
106            return Ok(None);
107        }
108
109        let version_output = String::from_utf8_lossy(&output.stdout);
110        let version_line = version_output.lines().next().unwrap_or("");
111
112        // Extract version number from output
113        let version_str = Self::extract_version_from_output(version_line)?;
114        let version = Version::parse(&version_str)?;
115
116        Ok(Some(version))
117    }
118
119    /// Get latest stable version from various sources
120    pub async fn get_latest_version(tool_name: &str) -> Result<Version> {
121        match tool_name {
122            "uv" => {
123                let fetcher = GitHubVersionFetcher::new("astral-sh", "uv");
124                let version_info = fetcher.get_latest_version().await?.ok_or_else(|| {
125                    VersionError::VersionNotFound {
126                        version: "latest".to_string(),
127                        tool: tool_name.to_string(),
128                    }
129                })?;
130                Version::parse(&version_info.version)
131            }
132            "node" => {
133                let fetcher = NodeVersionFetcher::new();
134                let version_info = fetcher.get_latest_version().await?.ok_or_else(|| {
135                    VersionError::VersionNotFound {
136                        version: "latest".to_string(),
137                        tool: tool_name.to_string(),
138                    }
139                })?;
140                Version::parse(&version_info.version)
141            }
142            _ => Err(VersionError::Other {
143                message: format!("Unsupported tool for version checking: {}", tool_name),
144            }),
145        }
146    }
147
148    /// Extract version string from command output
149    pub fn extract_version_from_output(output: &str) -> Result<String> {
150        // Common patterns for version extraction
151        let patterns = [
152            r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)",  // Standard semver
153            r"v(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)", // With 'v' prefix
154        ];
155
156        for pattern in &patterns {
157            if let Ok(re) = regex::Regex::new(pattern) {
158                if let Some(captures) = re.captures(output) {
159                    if let Some(version) = captures.get(1) {
160                        return Ok(version.as_str().to_string());
161                    }
162                }
163            }
164        }
165
166        Err(VersionError::Other {
167            message: format!("Could not extract version from output: {}", output),
168        })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_version_parsing() {
178        let version = Version::parse("1.2.3").unwrap();
179        assert_eq!(version.major, 1);
180        assert_eq!(version.minor, 2);
181        assert_eq!(version.patch, 3);
182        assert_eq!(version.pre, None);
183        assert!(!version.is_prerelease());
184
185        let prerelease = Version::parse("1.2.3-alpha.1").unwrap();
186        assert_eq!(prerelease.major, 1);
187        assert_eq!(prerelease.minor, 2);
188        assert_eq!(prerelease.patch, 3);
189        assert_eq!(prerelease.pre, Some("alpha.1".to_string()));
190        assert!(prerelease.is_prerelease());
191    }
192
193    #[test]
194    fn test_version_display() {
195        let version = Version::parse("1.2.3").unwrap();
196        assert_eq!(format!("{}", version), "1.2.3");
197
198        let prerelease = Version::parse("1.2.3-alpha.1").unwrap();
199        assert_eq!(format!("{}", prerelease), "1.2.3-alpha.1");
200    }
201
202    #[test]
203    fn test_version_comparison() {
204        let v1 = Version::parse("1.2.3").unwrap();
205        let v2 = Version::parse("1.2.4").unwrap();
206        let v3 = Version::parse("1.3.0").unwrap();
207
208        assert!(v1 < v2);
209        assert!(v2 < v3);
210        assert!(v1 < v3);
211    }
212
213    #[test]
214    fn test_extract_version_from_output() {
215        assert_eq!(
216            VersionManager::extract_version_from_output("node v18.17.0").unwrap(),
217            "18.17.0"
218        );
219        assert_eq!(
220            VersionManager::extract_version_from_output("uv 0.1.0").unwrap(),
221            "0.1.0"
222        );
223        assert_eq!(
224            VersionManager::extract_version_from_output("go version go1.21.0 linux/amd64").unwrap(),
225            "1.21.0"
226        );
227    }
228}