Skip to main content

deps_go/
version.rs

1//! Version parsing and module path utilities for Go modules.
2
3use deps_core::{DepsError, 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/// Escapes a Go version string for safe interpolation into proxy.golang.org
52/// `@v/{version}.info` and `@v/{version}.mod` URLs.
53///
54/// Mirrors `escape_module_path`'s case-encoding convention: uppercase letters
55/// become `!` + lowercase (the Go module-proxy case-folding rule), since a raw
56/// uppercase version segment — legal in a semver prerelease identifier such as
57/// `v1.7.0-RC` — otherwise 404s against the proxy. Every other character
58/// outside `[a-z0-9.+-]` is percent-encoded, preventing `?`, `#`, or
59/// whitespace from being interpreted as URL syntax (starting a query string
60/// or fragment) instead of literal version content.
61///
62/// # Examples
63///
64/// ```
65/// use deps_go::escape_version;
66///
67/// assert_eq!(escape_version("v1.9.1"), "v1.9.1");
68/// assert_eq!(escape_version("v2.0.0+incompatible"), "v2.0.0+incompatible");
69/// assert_eq!(escape_version("v1.7.0-RC"), "v1.7.0-!r!c");
70/// assert_eq!(escape_version("v1?a=b"), "v1%3Fa%3Db");
71/// ```
72pub fn escape_version(version: &str) -> String {
73    let mut result = String::with_capacity(version.len());
74
75    for c in version.chars() {
76        if c.is_uppercase() {
77            result.push('!');
78            result.push(c.to_ascii_lowercase());
79        } else if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+' {
80            result.push(c);
81        } else {
82            let mut buf = [0u8; 4];
83            let encoded = c.encode_utf8(&mut buf);
84            for &byte in encoded.as_bytes() {
85                result.push_str(&format!("%{byte:02X}"));
86            }
87        }
88    }
89
90    result
91}
92
93/// Checks if a version string is a pseudo-version.
94///
95/// Pseudo-version format: `vX.Y.Z-yyyymmddhhmmss-abcdefabcdef`
96///
97/// # Examples
98///
99/// ```
100/// use deps_go::is_pseudo_version;
101///
102/// assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5"));
103/// assert!(!is_pseudo_version("v1.2.3"));
104/// ```
105pub fn is_pseudo_version(version: &str) -> bool {
106    static PSEUDO_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
107        Regex::new(r"^v[0-9]+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+.*)?$").unwrap()
108    });
109
110    PSEUDO_REGEX.is_match(version)
111}
112
113/// Extracts the base version from a pseudo-version.
114///
115/// # Examples
116///
117/// ```
118/// use deps_go::base_version_from_pseudo;
119///
120/// assert_eq!(
121///     base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"),
122///     Some("v1.2.3".to_string())
123/// );
124/// ```
125pub fn base_version_from_pseudo(pseudo: &str) -> Option<String> {
126    if !is_pseudo_version(pseudo) {
127        return None;
128    }
129
130    let parts: Vec<&str> = pseudo.split('-').collect();
131    if parts.len() < 3 {
132        return None;
133    }
134
135    let version_part = parts[0];
136    let pre_release_part = parts[1];
137
138    if pre_release_part.starts_with('0') {
139        let semver = version_part.strip_prefix('v')?;
140        let mut components: Vec<u32> = semver.split('.').filter_map(|s| s.parse().ok()).collect();
141        if components.len() == 3 && components[2] > 0 {
142            components[2] -= 1;
143            return Some(format!(
144                "v{}.{}.{}",
145                components[0], components[1], components[2]
146            ));
147        }
148    }
149
150    Some(version_part.to_string())
151}
152
153/// Compares two Go versions using semantic versioning rules.
154///
155/// # Pseudo-version Handling
156///
157/// Pseudo-versions (e.g., `v0.0.0-20191109021931-daa7c04131f5`) are compared
158/// by their base version. For example, `v1.2.4-0.20191109021931-xxx` is treated
159/// as being based on `v1.2.3`.
160///
161/// # Incompatible Suffix
162///
163/// The `+incompatible` suffix is stripped before comparison.
164///
165/// # Returns
166///
167/// - `Ordering::Less` if v1 < v2
168/// - `Ordering::Equal` if v1 == v2
169/// - `Ordering::Greater` if v1 > v2
170///
171/// # Examples
172///
173/// ```
174/// use deps_go::compare_versions;
175/// use std::cmp::Ordering;
176///
177/// assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less);
178/// assert_eq!(compare_versions("v2.0.0+incompatible", "v2.0.0"), Ordering::Equal);
179/// ```
180pub fn compare_versions(v1: &str, v2: &str) -> Ordering {
181    let clean1 = v1.trim_start_matches('v').replace("+incompatible", "");
182    let clean2 = v2.trim_start_matches('v').replace("+incompatible", "");
183
184    let cmp1 = if is_pseudo_version(v1) {
185        base_version_from_pseudo(v1).unwrap_or(clean1)
186    } else {
187        clean1
188    };
189
190    let cmp2 = if is_pseudo_version(v2) {
191        base_version_from_pseudo(v2).unwrap_or(clean2)
192    } else {
193        clean2
194    };
195
196    match (parse_semver(&cmp1), parse_semver(&cmp2)) {
197        (Ok(ver1), Ok(ver2)) => ver1.cmp(&ver2),
198        _ => v1.cmp(v2),
199    }
200}
201
202fn parse_semver(version: &str) -> Result<semver::Version> {
203    let cleaned = version.trim_start_matches('v');
204
205    let split_at_prerelease = cleaned.split('-').next().unwrap_or(cleaned);
206
207    semver::Version::parse(split_at_prerelease)
208        .map_err(|e| DepsError::InvalidVersionReq(e.to_string()))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_escape_module_path() {
217        assert_eq!(
218            escape_module_path("github.com/User/Repo"),
219            "github.com/!user/!repo"
220        );
221        assert_eq!(
222            escape_module_path("github.com/gin-gonic/gin"),
223            "github.com/gin-gonic/gin"
224        );
225        assert_eq!(
226            escape_module_path("github.com/user/repo"),
227            "github.com/user/repo"
228        );
229    }
230
231    #[test]
232    fn test_escape_module_path_multiple_uppercase() {
233        assert_eq!(
234            escape_module_path("github.com/MyUser/MyRepo"),
235            "github.com/!my!user/!my!repo"
236        );
237    }
238
239    #[test]
240    fn test_escape_version_preserves_legitimate_versions() {
241        assert_eq!(escape_version("v1.9.1"), "v1.9.1");
242        assert_eq!(
243            escape_version("v0.0.0-20191109021931-daa7c04131f5"),
244            "v0.0.0-20191109021931-daa7c04131f5"
245        );
246        assert_eq!(escape_version("v2.0.0+incompatible"), "v2.0.0+incompatible");
247    }
248
249    #[test]
250    fn test_escape_version_encodes_query_and_fragment_chars() {
251        let escaped = escape_version("v1?a=b");
252        assert!(!escaped.contains('?'));
253        assert!(!escaped.contains('='));
254
255        let escaped = escape_version("v1#frag");
256        assert!(!escaped.contains('#'));
257
258        let escaped = escape_version("v1 space");
259        assert!(!escaped.contains(' '));
260    }
261
262    #[test]
263    fn test_escape_version_case_folds_uppercase() {
264        assert_eq!(escape_version("v1.7.0-RC"), "v1.7.0-!r!c");
265        assert_eq!(escape_version("V1.0.0"), "!v1.0.0");
266    }
267
268    #[test]
269    fn test_is_pseudo_version() {
270        assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5"));
271        assert!(is_pseudo_version("v1.2.4-0.20191109021931-daa7c04131f5"));
272        assert!(!is_pseudo_version("v1.2.3"));
273        assert!(!is_pseudo_version("v1.2.3-beta.1"));
274    }
275
276    #[test]
277    fn test_is_pseudo_version_with_incompatible() {
278        assert!(is_pseudo_version(
279            "v2.0.1-0.20191109021931-daa7c04131f5+incompatible"
280        ));
281    }
282
283    #[test]
284    fn test_base_version_from_pseudo() {
285        assert_eq!(
286            base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"),
287            Some("v1.2.3".to_string())
288        );
289        assert_eq!(
290            base_version_from_pseudo("v0.0.0-20191109021931-daa7c04131f5"),
291            Some("v0.0.0".to_string())
292        );
293    }
294
295    #[test]
296    fn test_base_version_from_pseudo_invalid() {
297        assert_eq!(base_version_from_pseudo("v1.2.3"), None);
298    }
299
300    #[test]
301    fn test_compare_versions() {
302        assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less);
303        assert_eq!(compare_versions("v1.2.3", "v1.2.3"), Ordering::Equal);
304        assert_eq!(compare_versions("v2.0.0", "v1.0.0"), Ordering::Greater);
305    }
306
307    #[test]
308    fn test_compare_versions_patch() {
309        assert_eq!(compare_versions("v1.2.3", "v1.2.4"), Ordering::Less);
310        assert_eq!(compare_versions("v1.2.5", "v1.2.4"), Ordering::Greater);
311    }
312
313    #[test]
314    fn test_compare_versions_minor() {
315        assert_eq!(compare_versions("v1.2.0", "v1.3.0"), Ordering::Less);
316        assert_eq!(compare_versions("v1.5.0", "v1.3.0"), Ordering::Greater);
317    }
318
319    #[test]
320    fn test_compare_versions_incompatible() {
321        assert_eq!(
322            compare_versions("v2.0.0+incompatible", "v2.1.0+incompatible"),
323            Ordering::Less
324        );
325    }
326
327    #[test]
328    fn test_parse_semver_valid() {
329        assert!(parse_semver("1.2.3").is_ok());
330        assert!(parse_semver("v1.2.3").is_ok());
331    }
332
333    #[test]
334    fn test_parse_semver_invalid() {
335        assert!(parse_semver("invalid").is_err());
336        assert!(parse_semver("v1.2").is_err());
337    }
338
339    #[test]
340    fn test_pseudo_regex_compiles() {
341        let _ = is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5");
342    }
343}