Skip to main content

_bver/
version.rs

1use crate::schema::FileKind;
2
3/// Validate a version string according to the file kind
4pub fn validate_version(version: &str, kind: FileKind) -> Result<(), String> {
5    match kind {
6        FileKind::Any => Ok(()),
7        FileKind::Simple => validate_simple(version),
8        FileKind::Python => validate_python(version),
9        FileKind::Semver => validate_semver(version),
10    }
11}
12
13/// Validate a simple semver version (N.N.N)
14fn validate_simple(version: &str) -> Result<(), String> {
15    let parts: Vec<&str> = version.split('.').collect();
16    if parts.len() != 3 {
17        return Err(format!(
18            "Invalid simple version: {version}. Expected format: major.minor.patch"
19        ));
20    }
21    for (i, part) in parts.iter().enumerate() {
22        let name = ["major", "minor", "patch"][i];
23        if part.parse::<u32>().is_err() {
24            return Err(format!("Invalid {name} version component: {part}"));
25        }
26    }
27    Ok(())
28}
29
30/// Validate a Python version string (PEP 440)
31/// https://peps.python.org/pep-0440/
32///
33/// Valid forms:
34/// - N[.N]+                           (e.g., 1.0, 1.0.0, 1.2.3.4)
35/// - N[.N]+{a|b|rc}N                  (e.g., 1.0a1, 1.0b2, 1.0rc1)
36/// - N[.N]+.postN                     (e.g., 1.0.post1)
37/// - N[.N]+.devN                      (e.g., 1.0.dev1)
38/// - N[.N]+{a|b|rc}N.postN            (e.g., 1.0a1.post1)
39/// - N[.N]+{a|b|rc}N.devN             (e.g., 1.0a1.dev1)
40/// - N[.N]+.postN.devN                (e.g., 1.0.post1.dev1)
41/// - N[.N]+{a|b|rc}N.postN.devN       (e.g., 1.0a1.post1.dev1)
42/// - Any of the above with +local     (e.g., 1.0+local.version)
43/// - Any of the above with N! prefix  (e.g., 1!1.0)
44fn validate_python(version: &str) -> Result<(), String> {
45    if version.is_empty() {
46        return Err("Version string cannot be empty".to_string());
47    }
48
49    let version = version.to_lowercase();
50
51    // Handle epoch (e.g., "1!1.0")
52    let version = if let Some(pos) = version.find('!') {
53        let epoch = &version[..pos];
54        if !epoch.chars().all(|c| c.is_ascii_digit()) {
55            return Err(format!("Invalid epoch: {epoch}"));
56        }
57        &version[pos + 1..]
58    } else {
59        version.as_str()
60    };
61
62    // Handle local version (e.g., "1.0+local")
63    let version = if let Some(pos) = version.find('+') {
64        let local = &version[pos + 1..];
65        if !is_valid_local(local) {
66            return Err(format!("Invalid local version: {local}"));
67        }
68        &version[..pos]
69    } else {
70        version
71    };
72
73    // Parse the main version parts
74    parse_main_version(version)
75}
76
77fn is_valid_local(local: &str) -> bool {
78    if local.is_empty() {
79        return false;
80    }
81    // Local version: alphanumerics and dots, segments separated by dots
82    local
83        .split('.')
84        .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_alphanumeric()))
85}
86
87fn parse_main_version(version: &str) -> Result<(), String> {
88    if version.is_empty() {
89        return Err("Version string cannot be empty".to_string());
90    }
91
92    // Try to find pre-release marker (a, b, rc, alpha, beta, preview, c)
93    let (release_part, remainder) = split_at_prerelease(version);
94
95    // Validate release part (N.N.N...)
96    if !is_valid_release(release_part) {
97        return Err(format!("Invalid release version: {release_part}"));
98    }
99
100    if remainder.is_empty() {
101        return Ok(());
102    }
103
104    // Parse pre-release, post-release, and dev markers
105    parse_suffixes(remainder)
106}
107
108fn split_at_prerelease(version: &str) -> (&str, &str) {
109    // Find first occurrence of pre-release markers
110    let markers = ["alpha", "beta", "preview", "rc", "a", "b", "c"];
111
112    let mut earliest_pos = None;
113
114    for marker in markers {
115        if let Some(pos) = version.find(marker) {
116            // Make sure it's not part of a segment (e.g., "1.0abc" should not match)
117            let before = &version[..pos];
118            if before.is_empty() || before.ends_with('.') || before.chars().last().unwrap().is_ascii_digit() {
119                match earliest_pos {
120                    None => earliest_pos = Some(pos),
121                    Some(current) if pos < current => earliest_pos = Some(pos),
122                    _ => {}
123                }
124            }
125        }
126    }
127
128    // Also check for .post and .dev at the start of suffix
129    if let Some(pos) = version.find(".post") {
130        match earliest_pos {
131            None => earliest_pos = Some(pos),
132            Some(current) if pos < current => earliest_pos = Some(pos),
133            _ => {}
134        }
135    }
136    if let Some(pos) = version.find(".dev") {
137        match earliest_pos {
138            None => earliest_pos = Some(pos),
139            Some(current) if pos < current => earliest_pos = Some(pos),
140            _ => {}
141        }
142    }
143
144    match earliest_pos {
145        Some(pos) => (&version[..pos], &version[pos..]),
146        None => (version, ""),
147    }
148}
149
150fn is_valid_release(release: &str) -> bool {
151    if release.is_empty() {
152        return false;
153    }
154
155    // Must be N or N.N or N.N.N etc.
156    release.split('.').all(|part| {
157        !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())
158    })
159}
160
161/// Validate a semver version string (used by npm, Cargo, etc.)
162/// https://semver.org/
163///
164/// Format: major.minor.patch[-prerelease][+build]
165/// - Prerelease: -alpha.1, -beta.2, -rc.1, etc.
166/// - Build metadata: +build.123 (ignored for precedence)
167///
168/// Note: post and dev releases are NOT supported in semver
169fn validate_semver(version: &str) -> Result<(), String> {
170    if version.is_empty() {
171        return Err("Version string cannot be empty".to_string());
172    }
173
174    // Split off build metadata (e.g., "1.0.0+build")
175    let version = if let Some(pos) = version.find('+') {
176        let build = &version[pos + 1..];
177        if build.is_empty() || !is_valid_semver_identifier(build) {
178            return Err(format!("Invalid build metadata: {build}"));
179        }
180        &version[..pos]
181    } else {
182        version
183    };
184
185    // Split off prerelease (e.g., "1.0.0-alpha.1")
186    let (release, prerelease) = if let Some(pos) = version.find('-') {
187        (&version[..pos], Some(&version[pos + 1..]))
188    } else {
189        (version, None)
190    };
191
192    // Validate release part (must be exactly major.minor.patch)
193    let parts: Vec<&str> = release.split('.').collect();
194    if parts.len() != 3 {
195        return Err(format!(
196            "Invalid semver: {release}. Expected format: major.minor.patch"
197        ));
198    }
199    for (i, part) in parts.iter().enumerate() {
200        let name = ["major", "minor", "patch"][i];
201        if part.parse::<u32>().is_err() {
202            return Err(format!("Invalid {name} version: {part}"));
203        }
204    }
205
206    // Validate prerelease if present
207    if let Some(pre) = prerelease
208        && (pre.is_empty() || !is_valid_semver_identifier(pre))
209    {
210        return Err(format!("Invalid prerelease: {pre}"));
211    }
212
213    Ok(())
214}
215
216fn is_valid_semver_identifier(id: &str) -> bool {
217    // Identifiers are dot-separated, each part is alphanumeric or hyphen
218    id.split('.').all(|part| {
219        !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
220    })
221}
222
223fn parse_suffixes(suffix: &str) -> Result<(), String> {
224    if suffix.is_empty() {
225        return Ok(());
226    }
227
228    let suffix = suffix.to_lowercase();
229    let mut remaining = suffix.as_str();
230
231    // Parse pre-release (a, b, rc, alpha, beta, preview, c)
232    let pre_markers = [
233        ("alpha", "a"),
234        ("beta", "b"),
235        ("preview", "rc"),
236        ("rc", "rc"),
237        ("a", "a"),
238        ("b", "b"),
239        ("c", "rc"),
240    ];
241
242    for (marker, _normalized) in pre_markers {
243        if remaining.starts_with(marker) {
244            remaining = &remaining[marker.len()..];
245            // Consume optional number
246            let num_end = remaining
247                .chars()
248                .take_while(|c| c.is_ascii_digit())
249                .count();
250            remaining = &remaining[num_end..];
251            break;
252        }
253    }
254
255    // Parse .post
256    if remaining.starts_with(".post") || remaining.starts_with("post") {
257        remaining = remaining.trim_start_matches('.').trim_start_matches("post");
258        let num_end = remaining
259            .chars()
260            .take_while(|c| c.is_ascii_digit())
261            .count();
262        remaining = &remaining[num_end..];
263    }
264
265    // Parse .dev
266    if remaining.starts_with(".dev") || remaining.starts_with("dev") {
267        remaining = remaining.trim_start_matches('.').trim_start_matches("dev");
268        let num_end = remaining
269            .chars()
270            .take_while(|c| c.is_ascii_digit())
271            .count();
272        remaining = &remaining[num_end..];
273    }
274
275    if remaining.is_empty() {
276        Ok(())
277    } else {
278        Err(format!("Invalid version suffix: {remaining}"))
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_valid_python_versions() {
288        assert!(validate_python("1").is_ok());
289        assert!(validate_python("1.0").is_ok());
290        assert!(validate_python("1.0.0").is_ok());
291        assert!(validate_python("1.2.3").is_ok());
292        assert!(validate_python("1.2.3.4").is_ok());
293        assert!(validate_python("0.0.1").is_ok());
294        assert!(validate_python("10.20.30").is_ok());
295    }
296
297    #[test]
298    fn test_valid_python_prerelease_versions() {
299        assert!(validate_python("1.0a1").is_ok());
300        assert!(validate_python("1.0b2").is_ok());
301        assert!(validate_python("1.0rc1").is_ok());
302        assert!(validate_python("1.0alpha1").is_ok());
303        assert!(validate_python("1.0beta2").is_ok());
304        assert!(validate_python("1.0.0a1").is_ok());
305        assert!(validate_python("1.0c1").is_ok());
306        assert!(validate_python("1.0preview1").is_ok());
307    }
308
309    #[test]
310    fn test_valid_python_post_versions() {
311        assert!(validate_python("1.0.post1").is_ok());
312        assert!(validate_python("1.0.0.post1").is_ok());
313        assert!(validate_python("1.0a1.post1").is_ok());
314    }
315
316    #[test]
317    fn test_valid_python_dev_versions() {
318        assert!(validate_python("1.0.dev1").is_ok());
319        assert!(validate_python("1.0.0.dev1").is_ok());
320        assert!(validate_python("1.0a1.dev1").is_ok());
321        assert!(validate_python("1.0.post1.dev1").is_ok());
322    }
323
324    #[test]
325    fn test_valid_python_epoch_versions() {
326        assert!(validate_python("1!1.0").is_ok());
327        assert!(validate_python("2!1.0.0").is_ok());
328    }
329
330    #[test]
331    fn test_valid_python_local_versions() {
332        assert!(validate_python("1.0+local").is_ok());
333        assert!(validate_python("1.0+local.version").is_ok());
334        assert!(validate_python("1.0+abc123").is_ok());
335        assert!(validate_python("1.0a1+local").is_ok());
336    }
337
338    #[test]
339    fn test_invalid_python_versions() {
340        assert!(validate_python("").is_err());
341        assert!(validate_python("a.b.c").is_err());
342        assert!(validate_python("1.0+").is_err());
343        assert!(validate_python("1.0.").is_err());
344        assert!(validate_python(".1.0").is_err());
345        assert!(validate_python("1..0").is_err());
346    }
347
348    #[test]
349    fn test_valid_semver_versions() {
350        assert!(validate_semver("1.0.0").is_ok());
351        assert!(validate_semver("1.2.3").is_ok());
352        assert!(validate_semver("0.0.1").is_ok());
353        assert!(validate_semver("10.20.30").is_ok());
354    }
355
356    #[test]
357    fn test_valid_semver_prerelease_versions() {
358        assert!(validate_semver("1.0.0-alpha.1").is_ok());
359        assert!(validate_semver("1.0.0-beta.2").is_ok());
360        assert!(validate_semver("1.0.0-rc.1").is_ok());
361        assert!(validate_semver("1.0.0-0").is_ok());
362        assert!(validate_semver("1.0.0-alpha").is_ok());
363    }
364
365    #[test]
366    fn test_valid_semver_build_versions() {
367        assert!(validate_semver("1.0.0+build").is_ok());
368        assert!(validate_semver("1.0.0+build.123").is_ok());
369        assert!(validate_semver("1.0.0-alpha.1+build").is_ok());
370    }
371
372    #[test]
373    fn test_invalid_semver_versions() {
374        assert!(validate_semver("").is_err());
375        assert!(validate_semver("1.0").is_err()); // Must have 3 parts
376        assert!(validate_semver("1").is_err());
377        assert!(validate_semver("1.0.0-").is_err());
378        assert!(validate_semver("1.0.0+").is_err());
379    }
380}