deps_go/
version.rs

1//! Version parsing and module path utilities for Go modules.
2
3use crate::error::{GoError, Result};
4use regex::Regex;
5use std::cmp::Ordering;
6
7/// Escapes a Go module path for proxy.golang.org API requests.
8///
9/// Rules:
10/// - Uppercase letters → `!lowercase` (e.g., `User` → `!user`)
11/// - Special characters percent-encoded (RFC 3986)
12///
13/// # Examples
14///
15/// ```
16/// use deps_go::escape_module_path;
17///
18/// assert_eq!(
19///     escape_module_path("github.com/User/Repo"),
20///     "github.com/!user/!repo"
21/// );
22/// ```
23pub fn escape_module_path(path: &str) -> String {
24    let mut result = String::with_capacity(path.len() + 10);
25
26    for c in path.chars() {
27        if c.is_uppercase() {
28            result.push('!');
29            result.push(c.to_ascii_lowercase());
30        } else if c.is_ascii_alphanumeric()
31            || c == '/'
32            || c == '-'
33            || c == '.'
34            || c == '_'
35            || c == '~'
36        {
37            result.push(c);
38        } else {
39            // Encode each byte of the UTF-8 representation
40            let mut buf = [0u8; 4];
41            let encoded = c.encode_utf8(&mut buf);
42            for &byte in encoded.as_bytes() {
43                result.push_str(&format!("%{byte:02X}"));
44            }
45        }
46    }
47
48    result
49}
50
51/// Checks if a version string is a pseudo-version.
52///
53/// Pseudo-version format: `vX.Y.Z-yyyymmddhhmmss-abcdefabcdef`
54///
55/// # Examples
56///
57/// ```
58/// use deps_go::is_pseudo_version;
59///
60/// assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5"));
61/// assert!(!is_pseudo_version("v1.2.3"));
62/// ```
63pub fn is_pseudo_version(version: &str) -> bool {
64    static PSEUDO_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
65        Regex::new(r"^v[0-9]+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+.*)?$").unwrap()
66    });
67
68    PSEUDO_REGEX.is_match(version)
69}
70
71/// Extracts the base version from a pseudo-version.
72///
73/// # Examples
74///
75/// ```
76/// use deps_go::base_version_from_pseudo;
77///
78/// assert_eq!(
79///     base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"),
80///     Some("v1.2.3".to_string())
81/// );
82/// ```
83pub fn base_version_from_pseudo(pseudo: &str) -> Option<String> {
84    if !is_pseudo_version(pseudo) {
85        return None;
86    }
87
88    let parts: Vec<&str> = pseudo.split('-').collect();
89    if parts.len() < 3 {
90        return None;
91    }
92
93    let version_part = parts[0];
94    let pre_release_part = parts[1];
95
96    if pre_release_part.starts_with('0') {
97        let semver = version_part.strip_prefix('v')?;
98        let mut components: Vec<u32> = semver.split('.').filter_map(|s| s.parse().ok()).collect();
99        if components.len() == 3 && components[2] > 0 {
100            components[2] -= 1;
101            return Some(format!(
102                "v{}.{}.{}",
103                components[0], components[1], components[2]
104            ));
105        }
106    }
107
108    Some(version_part.to_string())
109}
110
111/// Compares two Go versions using semantic versioning rules.
112///
113/// # Pseudo-version Handling
114///
115/// Pseudo-versions (e.g., `v0.0.0-20191109021931-daa7c04131f5`) are compared
116/// by their base version. For example, `v1.2.4-0.20191109021931-xxx` is treated
117/// as being based on `v1.2.3`.
118///
119/// # Incompatible Suffix
120///
121/// The `+incompatible` suffix is stripped before comparison.
122///
123/// # Returns
124///
125/// - `Ordering::Less` if v1 < v2
126/// - `Ordering::Equal` if v1 == v2
127/// - `Ordering::Greater` if v1 > v2
128///
129/// # Examples
130///
131/// ```
132/// use deps_go::compare_versions;
133/// use std::cmp::Ordering;
134///
135/// assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less);
136/// assert_eq!(compare_versions("v2.0.0+incompatible", "v2.0.0"), Ordering::Equal);
137/// ```
138pub fn compare_versions(v1: &str, v2: &str) -> Ordering {
139    let clean1 = v1.trim_start_matches('v').replace("+incompatible", "");
140    let clean2 = v2.trim_start_matches('v').replace("+incompatible", "");
141
142    let cmp1 = if is_pseudo_version(v1) {
143        base_version_from_pseudo(v1).unwrap_or(clean1)
144    } else {
145        clean1
146    };
147
148    let cmp2 = if is_pseudo_version(v2) {
149        base_version_from_pseudo(v2).unwrap_or(clean2)
150    } else {
151        clean2
152    };
153
154    match (parse_semver(&cmp1), parse_semver(&cmp2)) {
155        (Ok(ver1), Ok(ver2)) => ver1.cmp(&ver2),
156        _ => v1.cmp(v2),
157    }
158}
159
160fn parse_semver(version: &str) -> Result<semver::Version> {
161    let cleaned = version.trim_start_matches('v');
162
163    let split_at_prerelease = cleaned.split('-').next().unwrap_or(cleaned);
164
165    semver::Version::parse(split_at_prerelease).map_err(|e| GoError::InvalidVersionSpecifier {
166        specifier: version.to_string(),
167        message: e.to_string(),
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_escape_module_path() {
177        assert_eq!(
178            escape_module_path("github.com/User/Repo"),
179            "github.com/!user/!repo"
180        );
181        assert_eq!(
182            escape_module_path("github.com/gin-gonic/gin"),
183            "github.com/gin-gonic/gin"
184        );
185        assert_eq!(
186            escape_module_path("github.com/user/repo"),
187            "github.com/user/repo"
188        );
189    }
190
191    #[test]
192    fn test_escape_module_path_multiple_uppercase() {
193        assert_eq!(
194            escape_module_path("github.com/MyUser/MyRepo"),
195            "github.com/!my!user/!my!repo"
196        );
197    }
198
199    #[test]
200    fn test_is_pseudo_version() {
201        assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5"));
202        assert!(is_pseudo_version("v1.2.4-0.20191109021931-daa7c04131f5"));
203        assert!(!is_pseudo_version("v1.2.3"));
204        assert!(!is_pseudo_version("v1.2.3-beta.1"));
205    }
206
207    #[test]
208    fn test_is_pseudo_version_with_incompatible() {
209        assert!(is_pseudo_version(
210            "v2.0.1-0.20191109021931-daa7c04131f5+incompatible"
211        ));
212    }
213
214    #[test]
215    fn test_base_version_from_pseudo() {
216        assert_eq!(
217            base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"),
218            Some("v1.2.3".to_string())
219        );
220        assert_eq!(
221            base_version_from_pseudo("v0.0.0-20191109021931-daa7c04131f5"),
222            Some("v0.0.0".to_string())
223        );
224    }
225
226    #[test]
227    fn test_base_version_from_pseudo_invalid() {
228        assert_eq!(base_version_from_pseudo("v1.2.3"), None);
229    }
230
231    #[test]
232    fn test_compare_versions() {
233        assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less);
234        assert_eq!(compare_versions("v1.2.3", "v1.2.3"), Ordering::Equal);
235        assert_eq!(compare_versions("v2.0.0", "v1.0.0"), Ordering::Greater);
236    }
237
238    #[test]
239    fn test_compare_versions_patch() {
240        assert_eq!(compare_versions("v1.2.3", "v1.2.4"), Ordering::Less);
241        assert_eq!(compare_versions("v1.2.5", "v1.2.4"), Ordering::Greater);
242    }
243
244    #[test]
245    fn test_compare_versions_minor() {
246        assert_eq!(compare_versions("v1.2.0", "v1.3.0"), Ordering::Less);
247        assert_eq!(compare_versions("v1.5.0", "v1.3.0"), Ordering::Greater);
248    }
249
250    #[test]
251    fn test_compare_versions_incompatible() {
252        assert_eq!(
253            compare_versions("v2.0.0+incompatible", "v2.1.0+incompatible"),
254            Ordering::Less
255        );
256    }
257
258    #[test]
259    fn test_parse_semver_valid() {
260        assert!(parse_semver("1.2.3").is_ok());
261        assert!(parse_semver("v1.2.3").is_ok());
262    }
263
264    #[test]
265    fn test_parse_semver_invalid() {
266        assert!(parse_semver("invalid").is_err());
267        assert!(parse_semver("v1.2").is_err());
268    }
269
270    #[test]
271    fn test_pseudo_regex_compiles() {
272        let _ = is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5");
273    }
274}