Skip to main content

_bver/
cast.rs

1use crate::schema::FileKind;
2
3/// Cast a version string to the target kind, potentially losing information.
4/// Returns the casted version string or an error if casting is not possible.
5pub fn cast_version(version: &str, target_kind: FileKind) -> Result<String, String> {
6    match target_kind {
7        FileKind::Any => Ok(version.to_string()),
8        FileKind::Simple => cast_to_simple(version),
9        FileKind::Python => cast_to_python(version),
10        FileKind::Semver => cast_to_semver(version),
11    }
12}
13
14/// Cast any version to simple semver (major.minor.patch).
15/// Strips pre-release, post-release, dev, local, and epoch information.
16fn cast_to_simple(version: &str) -> Result<String, String> {
17    let version = version.to_lowercase();
18
19    // Remove epoch (e.g., "1!1.0" -> "1.0")
20    let version = if let Some(pos) = version.find('!') {
21        &version[pos + 1..]
22    } else {
23        version.as_str()
24    };
25
26    // Remove local version (e.g., "1.0+local" -> "1.0")
27    let version = if let Some(pos) = version.find('+') {
28        &version[..pos]
29    } else {
30        version
31    };
32
33    // Find where the release version ends (before any pre/post/dev markers)
34    let release_end = find_release_end(version);
35    let release = &version[..release_end];
36
37    // Parse the release parts
38    let parts: Vec<&str> = release.split('.').collect();
39
40    if parts.is_empty() {
41        return Err(format!("Cannot cast '{version}' to simple version: no version parts found"));
42    }
43
44    // Validate all parts are numeric
45    for part in &parts {
46        if part.parse::<u32>().is_err() {
47            return Err(format!("Cannot cast '{version}' to simple version: invalid part '{part}'"));
48        }
49    }
50
51    // Pad or truncate to exactly 3 parts
52    let major = parts.first().unwrap_or(&"0");
53    let minor = parts.get(1).unwrap_or(&"0");
54    let patch = parts.get(2).unwrap_or(&"0");
55
56    Ok(format!("{major}.{minor}.{patch}"))
57}
58
59/// Cast any version to PEP 440 format.
60/// Most versions are already valid or can be normalized.
61fn cast_to_python(version: &str) -> Result<String, String> {
62    // Simple semver is valid PEP 440
63    let parts: Vec<&str> = version.split('.').collect();
64    if parts.iter().all(|p| p.parse::<u32>().is_ok()) {
65        return Ok(version.to_string());
66    }
67
68    // Already a valid Python version (assume it's fine)
69    // The validator will catch any issues
70    Ok(version.to_string())
71}
72
73/// Cast any version to semver format (used by npm, Cargo, etc.).
74/// Converts Python-style prereleases to semver-style (e.g., 1.2.3a1 -> 1.2.3-alpha.1)
75/// Strips post and dev releases as they're not supported in semver.
76fn cast_to_semver(version: &str) -> Result<String, String> {
77    let version = version.to_lowercase();
78
79    // Remove epoch (e.g., "1!1.0" -> "1.0")
80    let version = if let Some(pos) = version.find('!') {
81        &version[pos + 1..]
82    } else {
83        version.as_str()
84    };
85
86    // Remove local version (e.g., "1.0+local" -> "1.0")
87    let version = if let Some(pos) = version.find('+') {
88        &version[..pos]
89    } else {
90        version
91    };
92
93    // Find where the release version ends
94    let release_end = find_release_end(version);
95    let release = &version[..release_end];
96    let suffix = &version[release_end..];
97
98    // Parse the release parts and ensure we have exactly 3
99    let parts: Vec<&str> = release.split('.').collect();
100    if parts.is_empty() {
101        return Err(format!("Cannot cast '{version}' to semver: no version parts found"));
102    }
103
104    for part in &parts {
105        if part.parse::<u32>().is_err() {
106            return Err(format!("Cannot cast '{version}' to semver: invalid part '{part}'"));
107        }
108    }
109
110    let major = parts.first().unwrap_or(&"0");
111    let minor = parts.get(1).unwrap_or(&"0");
112    let patch = parts.get(2).unwrap_or(&"0");
113    let base = format!("{major}.{minor}.{patch}");
114
115    // Convert Python prerelease to JS format
116    if suffix.is_empty() {
117        return Ok(base);
118    }
119
120    // Strip .post and .dev as they're not supported
121    let suffix = suffix
122        .split(".post")
123        .next()
124        .unwrap_or(suffix)
125        .split(".dev")
126        .next()
127        .unwrap_or(suffix);
128
129    if suffix.is_empty() {
130        return Ok(base);
131    }
132
133    // Convert a1 -> -alpha.1, b1 -> -beta.1, rc1 -> -rc.1
134    let js_prerelease = if let Some(rest) = suffix.strip_prefix("alpha") {
135        format!("-alpha.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
136    } else if let Some(rest) = suffix.strip_prefix('a') {
137        format!("-alpha.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
138    } else if let Some(rest) = suffix.strip_prefix("beta") {
139        format!("-beta.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
140    } else if let Some(rest) = suffix.strip_prefix('b') {
141        format!("-beta.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
142    } else if let Some(rest) = suffix.strip_prefix("rc") {
143        format!("-rc.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
144    } else if let Some(rest) = suffix.strip_prefix('c') {
145        format!("-rc.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
146    } else if let Some(rest) = suffix.strip_prefix("preview") {
147        format!("-rc.{}", rest.trim_start_matches(|c: char| !c.is_ascii_digit()))
148    } else {
149        // Unknown suffix, strip it
150        return Ok(base);
151    };
152
153    Ok(format!("{base}{js_prerelease}"))
154}
155
156/// Find the end position of the release version (before pre/post/dev markers).
157fn find_release_end(version: &str) -> usize {
158    let markers = ["a", "b", "c", "alpha", "beta", "preview", "rc", ".post", ".dev", "-"];
159
160    let mut earliest = version.len();
161
162    for marker in markers {
163        if let Some(pos) = version.find(marker) {
164            // Make sure we're not matching in the middle of a number
165            let before = &version[..pos];
166            if before.is_empty() || before.ends_with('.') || before.chars().last().unwrap().is_ascii_digit() {
167                earliest = earliest.min(pos);
168            }
169        }
170    }
171
172    earliest
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_cast_to_simple() {
181        // Already simple
182        assert_eq!(cast_to_simple("1.2.3").unwrap(), "1.2.3");
183
184        // Pad missing parts
185        assert_eq!(cast_to_simple("1").unwrap(), "1.0.0");
186        assert_eq!(cast_to_simple("1.2").unwrap(), "1.2.0");
187
188        // Strip pre-release
189        assert_eq!(cast_to_simple("1.2.3a1").unwrap(), "1.2.3");
190        assert_eq!(cast_to_simple("1.2.3b2").unwrap(), "1.2.3");
191        assert_eq!(cast_to_simple("1.2.3rc1").unwrap(), "1.2.3");
192        assert_eq!(cast_to_simple("1.2.3alpha1").unwrap(), "1.2.3");
193        assert_eq!(cast_to_simple("1.2.3beta2").unwrap(), "1.2.3");
194
195        // Strip post-release
196        assert_eq!(cast_to_simple("1.2.3.post1").unwrap(), "1.2.3");
197
198        // Strip dev
199        assert_eq!(cast_to_simple("1.2.3.dev1").unwrap(), "1.2.3");
200
201        // Strip local
202        assert_eq!(cast_to_simple("1.2.3+local").unwrap(), "1.2.3");
203
204        // Strip epoch
205        assert_eq!(cast_to_simple("1!1.2.3").unwrap(), "1.2.3");
206
207        // Combined
208        assert_eq!(cast_to_simple("1!1.2.3a1.post1.dev1+local").unwrap(), "1.2.3");
209    }
210
211    #[test]
212    fn test_cast_to_python() {
213        // Simple versions pass through
214        assert_eq!(cast_to_python("1.2.3").unwrap(), "1.2.3");
215
216        // Python versions pass through
217        assert_eq!(cast_to_python("1.2.3a1").unwrap(), "1.2.3a1");
218    }
219}