agpm_cli/version/
comparison.rs

1//! Version comparison utilities for semantic version handling.
2//!
3//! This module provides utilities for comparing semantic versions, checking for
4//! newer versions, and handling version parsing operations used throughout the
5//! dependency resolution process. It supports common version prefixes and
6//! provides error-handling for malformed version strings.
7//!
8//! # Features
9//!
10//! - **Semantic Version Parsing**: Handles `v1.2.3`, `version-1.2.3`, `release-1.2.3` formats
11//! - **Version Comparison**: Find newer versions and latest versions in collections
12//! - **Prefix Handling**: Automatically strips common version prefixes
13//! - **Error Resilience**: Gracefully handles malformed version strings
14//!
15//! # Examples
16//!
17//! ```rust,no_run
18//! use agpm_cli::version::comparison::VersionComparator;
19//!
20//! # fn example() -> anyhow::Result<()> {
21//! let versions = vec![
22//!     "v1.0.0".to_string(),
23//!     "v1.1.0".to_string(),
24//!     "v2.0.0".to_string(),
25//!     "version-1.0.1".to_string(),
26//! ];
27//!
28//! // Check if there are newer versions
29//! let has_newer = VersionComparator::has_newer_version("v1.0.0", &versions)?;
30//! assert!(has_newer);
31//!
32//! // Get the latest version
33//! let latest = VersionComparator::get_latest(&versions)?
34//!     .expect("Should find latest version");
35//! assert_eq!(latest, "v2.0.0");
36//!
37//! // Get all newer versions than current
38//! let newer = VersionComparator::get_newer_versions("v1.0.0", &versions)?;
39//! assert_eq!(newer.len(), 3); // v1.1.0, v2.0.0, version-1.0.1
40//! # Ok(())
41//! # }
42//! ```
43
44use anyhow::Result;
45use semver::Version;
46
47/// Version comparison utilities for semantic version operations.
48///
49/// This struct provides static methods for comparing semantic versions,
50/// finding newer versions, and handling version parsing with common prefixes.
51/// All methods are designed to handle malformed version strings gracefully.
52pub struct VersionComparator;
53
54impl VersionComparator {
55    /// Checks if there are newer versions available than the current version.
56    ///
57    /// This method compares the current version against a list of available versions
58    /// and returns `true` if any version is semantically newer.
59    ///
60    /// # Arguments
61    ///
62    /// * `current` - The current version string (e.g., "v1.0.0", "1.2.3")
63    /// * `versions` - A slice of version strings to compare against
64    ///
65    /// # Returns
66    ///
67    /// `Ok(true)` if newer versions exist, `Ok(false)` if current is latest.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the current version string cannot be parsed as a
72    /// semantic version. Malformed versions in the comparison list are ignored.
73    ///
74    /// # Examples
75    ///
76    /// ```rust,no_run
77    /// use agpm_cli::version::comparison::VersionComparator;
78    ///
79    /// # fn example() -> anyhow::Result<()> {
80    /// let versions = vec!["v1.0.0".to_string(), "v1.1.0".to_string(), "v2.0.0".to_string()];
81    ///
82    /// // Check if v1.0.0 has newer versions available
83    /// let has_newer = VersionComparator::has_newer_version("v1.0.0", &versions)?;
84    /// assert!(has_newer);
85    ///
86    /// // Check if v2.0.0 is the latest
87    /// let has_newer = VersionComparator::has_newer_version("v2.0.0", &versions)?;
88    /// assert!(!has_newer);
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub fn has_newer_version(current: &str, versions: &[String]) -> Result<bool> {
93        let current_version = Self::parse_version(current)?;
94
95        for version_str in versions {
96            if let Ok(version) = Self::parse_version(version_str)
97                && version > current_version
98            {
99                return Ok(true);
100            }
101        }
102
103        Ok(false)
104    }
105
106    /// Gets all versions newer than the current version, sorted by version descending.
107    ///
108    /// This method finds all versions in the provided list that are semantically
109    /// newer than the current version and returns them sorted from newest to oldest.
110    ///
111    /// # Arguments
112    ///
113    /// * `current` - The current version string to compare against
114    /// * `versions` - A slice of version strings to search
115    ///
116    /// # Returns
117    ///
118    /// A vector of references to version strings that are newer than current,
119    /// sorted in descending order (newest first).
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the current version string cannot be parsed.
124    /// Malformed versions in the search list are silently ignored.
125    ///
126    /// # Examples
127    ///
128    /// ```rust,no_run
129    /// use agpm_cli::version::comparison::VersionComparator;
130    ///
131    /// # fn example() -> anyhow::Result<()> {
132    /// let versions = vec![
133    ///     "v1.0.0".to_string(),
134    ///     "v1.2.0".to_string(),
135    ///     "v1.1.0".to_string(),
136    ///     "v2.0.0".to_string(),
137    /// ];
138    ///
139    /// let newer = VersionComparator::get_newer_versions("v1.0.0", &versions)?;
140    /// assert_eq!(newer.len(), 3);
141    /// // Results are sorted newest first
142    /// assert_eq!(newer[0], "v2.0.0");
143    /// assert_eq!(newer[1], "v1.2.0");
144    /// assert_eq!(newer[2], "v1.1.0");
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub fn get_newer_versions<'a>(
149        current: &str,
150        versions: &'a [String],
151    ) -> Result<Vec<&'a String>> {
152        let current_version = Self::parse_version(current)?;
153        let mut newer = Vec::new();
154
155        for version_str in versions {
156            if let Ok(version) = Self::parse_version(version_str)
157                && version > current_version
158            {
159                newer.push(version_str);
160            }
161        }
162
163        // Sort by version descending
164        newer.sort_by(|a, b| {
165            let v1 = Self::parse_version(a).unwrap_or_else(|_| Version::new(0, 0, 0));
166            let v2 = Self::parse_version(b).unwrap_or_else(|_| Version::new(0, 0, 0));
167            v2.cmp(&v1)
168        });
169
170        Ok(newer)
171    }
172
173    /// Gets the latest (highest) semantic version from a list of versions.
174    ///
175    /// This method finds the semantically highest version from the provided list,
176    /// ignoring any malformed version strings.
177    ///
178    /// # Arguments
179    ///
180    /// * `versions` - A slice of version strings to search
181    ///
182    /// # Returns
183    ///
184    /// `Ok(Some(&String))` with the latest version, or `Ok(None)` if the list is
185    /// empty or contains no valid semantic versions.
186    ///
187    /// # Errors
188    ///
189    /// This method does not return errors - malformed version strings are silently
190    /// ignored during comparison.
191    ///
192    /// # Examples
193    ///
194    /// ```rust,no_run
195    /// use agpm_cli::version::comparison::VersionComparator;
196    ///
197    /// # fn example() -> anyhow::Result<()> {
198    /// let versions = vec![
199    ///     "v1.0.0".to_string(),
200    ///     "v2.0.0".to_string(),
201    ///     "v1.5.0".to_string(),
202    ///     "invalid-version".to_string(), // This will be ignored
203    /// ];
204    ///
205    /// let latest = VersionComparator::get_latest(&versions)?
206    ///     .expect("Should find a latest version");
207    /// assert_eq!(latest, "v2.0.0");
208    ///
209    /// // Empty list returns None
210    /// let empty: Vec<String> = vec![];
211    /// assert!(VersionComparator::get_latest(&empty)?.is_none());
212    /// # Ok(())
213    /// # }
214    /// ```
215    pub fn get_latest(versions: &[String]) -> Result<Option<&String>> {
216        if versions.is_empty() {
217            return Ok(None);
218        }
219
220        let mut latest: Option<(&String, Version)> = None;
221
222        for version_str in versions {
223            if let Ok(version) = Self::parse_version(version_str)
224                && (latest.is_none() || version > latest.as_ref().unwrap().1)
225            {
226                latest = Some((version_str, version));
227            }
228        }
229
230        Ok(latest.map(|(s, _)| s))
231    }
232
233    /// Parses a version string, automatically handling common prefixes.
234    ///
235    /// This private method normalizes version strings by removing common prefixes
236    /// like "v", "version-", and "release-" before parsing with the semver crate.
237    ///
238    /// # Supported Prefixes
239    ///
240    /// - `v1.2.3` → `1.2.3`
241    /// - `version-1.2.3` → `1.2.3`
242    /// - `release-1.2.3` → `1.2.3`
243    /// - `1.2.3` → `1.2.3` (no change)
244    ///
245    /// # Arguments
246    ///
247    /// * `version_str` - The version string to parse
248    ///
249    /// # Returns
250    ///
251    /// A parsed `semver::Version` instance.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the version string (after prefix removal) is not
256    /// a valid semantic version according to the semver specification.
257    fn parse_version(version_str: &str) -> Result<Version> {
258        // Remove common version prefixes
259        let clean_version = if let Some(stripped) = version_str.strip_prefix("version-") {
260            stripped
261        } else if let Some(stripped) = version_str.strip_prefix("release-") {
262            stripped
263        } else if let Some(stripped) = version_str.strip_prefix('v') {
264            stripped
265        } else {
266            version_str
267        };
268
269        Ok(Version::parse(clean_version)?)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_has_newer_version() {
279        let versions = vec!["v1.0.0".to_string(), "v1.1.0".to_string(), "v2.0.0".to_string()];
280
281        assert!(VersionComparator::has_newer_version("1.0.0", &versions).unwrap());
282        assert!(VersionComparator::has_newer_version("v1.0.0", &versions).unwrap());
283        assert!(!VersionComparator::has_newer_version("2.0.0", &versions).unwrap());
284        assert!(!VersionComparator::has_newer_version("v3.0.0", &versions).unwrap());
285    }
286
287    #[test]
288    fn test_get_newer_versions() {
289        let versions = vec![
290            "v1.0.0".to_string(),
291            "v1.1.0".to_string(),
292            "v2.0.0".to_string(),
293            "v0.9.0".to_string(),
294        ];
295
296        let newer = VersionComparator::get_newer_versions("1.0.0", &versions).unwrap();
297        assert_eq!(newer.len(), 2);
298        assert_eq!(newer[0], "v2.0.0");
299        assert_eq!(newer[1], "v1.1.0");
300
301        let newer = VersionComparator::get_newer_versions("2.0.0", &versions).unwrap();
302        assert_eq!(newer.len(), 0);
303    }
304
305    #[test]
306    fn test_get_latest() {
307        let versions = vec![
308            "v1.0.0".to_string(),
309            "v1.1.0".to_string(),
310            "v2.0.0".to_string(),
311            "v0.9.0".to_string(),
312        ];
313
314        let latest = VersionComparator::get_latest(&versions).unwrap();
315        assert_eq!(latest, Some(&"v2.0.0".to_string()));
316
317        let empty: Vec<String> = vec![];
318        let latest = VersionComparator::get_latest(&empty).unwrap();
319        assert_eq!(latest, None);
320    }
321
322    #[test]
323    fn test_parse_version() {
324        assert_eq!(VersionComparator::parse_version("1.0.0").unwrap(), Version::new(1, 0, 0));
325        assert_eq!(VersionComparator::parse_version("v1.0.0").unwrap(), Version::new(1, 0, 0));
326        assert_eq!(
327            VersionComparator::parse_version("version-1.0.0").unwrap(),
328            Version::new(1, 0, 0)
329        );
330        assert_eq!(
331            VersionComparator::parse_version("release-1.0.0").unwrap(),
332            Version::new(1, 0, 0)
333        );
334    }
335}