use crate::schema::FileKind;
pub fn validate_version(version: &str, kind: FileKind) -> Result<(), String> {
match kind {
FileKind::Any => Ok(()),
FileKind::Simple => validate_simple(version),
FileKind::Python => validate_python(version),
FileKind::Semver => validate_semver(version),
}
}
fn validate_simple(version: &str) -> Result<(), String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return Err(format!(
"Invalid simple version: {version}. Expected format: major.minor.patch"
));
}
for (i, part) in parts.iter().enumerate() {
let name = ["major", "minor", "patch"][i];
if part.parse::<u32>().is_err() {
return Err(format!("Invalid {name} version component: {part}"));
}
}
Ok(())
}
fn validate_python(version: &str) -> Result<(), String> {
if version.is_empty() {
return Err("Version string cannot be empty".to_string());
}
let version = version.to_lowercase();
let version = if let Some(pos) = version.find('!') {
let epoch = &version[..pos];
if !epoch.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("Invalid epoch: {epoch}"));
}
&version[pos + 1..]
} else {
version.as_str()
};
let version = if let Some(pos) = version.find('+') {
let local = &version[pos + 1..];
if !is_valid_local(local) {
return Err(format!("Invalid local version: {local}"));
}
&version[..pos]
} else {
version
};
parse_main_version(version)
}
fn is_valid_local(local: &str) -> bool {
if local.is_empty() {
return false;
}
local
.split('.')
.all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_alphanumeric()))
}
fn parse_main_version(version: &str) -> Result<(), String> {
if version.is_empty() {
return Err("Version string cannot be empty".to_string());
}
let (release_part, remainder) = split_at_prerelease(version);
if !is_valid_release(release_part) {
return Err(format!("Invalid release version: {release_part}"));
}
if remainder.is_empty() {
return Ok(());
}
parse_suffixes(remainder)
}
fn split_at_prerelease(version: &str) -> (&str, &str) {
let markers = ["alpha", "beta", "preview", "rc", "a", "b", "c"];
let mut earliest_pos = None;
for marker in markers {
if let Some(pos) = version.find(marker) {
let before = &version[..pos];
if before.is_empty() || before.ends_with('.') || before.chars().last().unwrap().is_ascii_digit() {
match earliest_pos {
None => earliest_pos = Some(pos),
Some(current) if pos < current => earliest_pos = Some(pos),
_ => {}
}
}
}
}
if let Some(pos) = version.find(".post") {
match earliest_pos {
None => earliest_pos = Some(pos),
Some(current) if pos < current => earliest_pos = Some(pos),
_ => {}
}
}
if let Some(pos) = version.find(".dev") {
match earliest_pos {
None => earliest_pos = Some(pos),
Some(current) if pos < current => earliest_pos = Some(pos),
_ => {}
}
}
match earliest_pos {
Some(pos) => (&version[..pos], &version[pos..]),
None => (version, ""),
}
}
fn is_valid_release(release: &str) -> bool {
if release.is_empty() {
return false;
}
release.split('.').all(|part| {
!part.is_empty() && part.chars().all(|c| c.is_ascii_digit())
})
}
fn validate_semver(version: &str) -> Result<(), String> {
if version.is_empty() {
return Err("Version string cannot be empty".to_string());
}
let version = if let Some(pos) = version.find('+') {
let build = &version[pos + 1..];
if build.is_empty() || !is_valid_semver_identifier(build) {
return Err(format!("Invalid build metadata: {build}"));
}
&version[..pos]
} else {
version
};
let (release, prerelease) = if let Some(pos) = version.find('-') {
(&version[..pos], Some(&version[pos + 1..]))
} else {
(version, None)
};
let parts: Vec<&str> = release.split('.').collect();
if parts.len() != 3 {
return Err(format!(
"Invalid semver: {release}. Expected format: major.minor.patch"
));
}
for (i, part) in parts.iter().enumerate() {
let name = ["major", "minor", "patch"][i];
if part.parse::<u32>().is_err() {
return Err(format!("Invalid {name} version: {part}"));
}
}
if let Some(pre) = prerelease
&& (pre.is_empty() || !is_valid_semver_identifier(pre))
{
return Err(format!("Invalid prerelease: {pre}"));
}
Ok(())
}
fn is_valid_semver_identifier(id: &str) -> bool {
id.split('.').all(|part| {
!part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
}
fn parse_suffixes(suffix: &str) -> Result<(), String> {
if suffix.is_empty() {
return Ok(());
}
let suffix = suffix.to_lowercase();
let mut remaining = suffix.as_str();
let pre_markers = [
("alpha", "a"),
("beta", "b"),
("preview", "rc"),
("rc", "rc"),
("a", "a"),
("b", "b"),
("c", "rc"),
];
for (marker, _normalized) in pre_markers {
if remaining.starts_with(marker) {
remaining = &remaining[marker.len()..];
let num_end = remaining
.chars()
.take_while(|c| c.is_ascii_digit())
.count();
remaining = &remaining[num_end..];
break;
}
}
if remaining.starts_with(".post") || remaining.starts_with("post") {
remaining = remaining.trim_start_matches('.').trim_start_matches("post");
let num_end = remaining
.chars()
.take_while(|c| c.is_ascii_digit())
.count();
remaining = &remaining[num_end..];
}
if remaining.starts_with(".dev") || remaining.starts_with("dev") {
remaining = remaining.trim_start_matches('.').trim_start_matches("dev");
let num_end = remaining
.chars()
.take_while(|c| c.is_ascii_digit())
.count();
remaining = &remaining[num_end..];
}
if remaining.is_empty() {
Ok(())
} else {
Err(format!("Invalid version suffix: {remaining}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_python_versions() {
assert!(validate_python("1").is_ok());
assert!(validate_python("1.0").is_ok());
assert!(validate_python("1.0.0").is_ok());
assert!(validate_python("1.2.3").is_ok());
assert!(validate_python("1.2.3.4").is_ok());
assert!(validate_python("0.0.1").is_ok());
assert!(validate_python("10.20.30").is_ok());
}
#[test]
fn test_valid_python_prerelease_versions() {
assert!(validate_python("1.0a1").is_ok());
assert!(validate_python("1.0b2").is_ok());
assert!(validate_python("1.0rc1").is_ok());
assert!(validate_python("1.0alpha1").is_ok());
assert!(validate_python("1.0beta2").is_ok());
assert!(validate_python("1.0.0a1").is_ok());
assert!(validate_python("1.0c1").is_ok());
assert!(validate_python("1.0preview1").is_ok());
}
#[test]
fn test_valid_python_post_versions() {
assert!(validate_python("1.0.post1").is_ok());
assert!(validate_python("1.0.0.post1").is_ok());
assert!(validate_python("1.0a1.post1").is_ok());
}
#[test]
fn test_valid_python_dev_versions() {
assert!(validate_python("1.0.dev1").is_ok());
assert!(validate_python("1.0.0.dev1").is_ok());
assert!(validate_python("1.0a1.dev1").is_ok());
assert!(validate_python("1.0.post1.dev1").is_ok());
}
#[test]
fn test_valid_python_epoch_versions() {
assert!(validate_python("1!1.0").is_ok());
assert!(validate_python("2!1.0.0").is_ok());
}
#[test]
fn test_valid_python_local_versions() {
assert!(validate_python("1.0+local").is_ok());
assert!(validate_python("1.0+local.version").is_ok());
assert!(validate_python("1.0+abc123").is_ok());
assert!(validate_python("1.0a1+local").is_ok());
}
#[test]
fn test_invalid_python_versions() {
assert!(validate_python("").is_err());
assert!(validate_python("a.b.c").is_err());
assert!(validate_python("1.0+").is_err());
assert!(validate_python("1.0.").is_err());
assert!(validate_python(".1.0").is_err());
assert!(validate_python("1..0").is_err());
}
#[test]
fn test_valid_semver_versions() {
assert!(validate_semver("1.0.0").is_ok());
assert!(validate_semver("1.2.3").is_ok());
assert!(validate_semver("0.0.1").is_ok());
assert!(validate_semver("10.20.30").is_ok());
}
#[test]
fn test_valid_semver_prerelease_versions() {
assert!(validate_semver("1.0.0-alpha.1").is_ok());
assert!(validate_semver("1.0.0-beta.2").is_ok());
assert!(validate_semver("1.0.0-rc.1").is_ok());
assert!(validate_semver("1.0.0-0").is_ok());
assert!(validate_semver("1.0.0-alpha").is_ok());
}
#[test]
fn test_valid_semver_build_versions() {
assert!(validate_semver("1.0.0+build").is_ok());
assert!(validate_semver("1.0.0+build.123").is_ok());
assert!(validate_semver("1.0.0-alpha.1+build").is_ok());
}
#[test]
fn test_invalid_semver_versions() {
assert!(validate_semver("").is_err());
assert!(validate_semver("1.0").is_err()); assert!(validate_semver("1").is_err());
assert!(validate_semver("1.0.0-").is_err());
assert!(validate_semver("1.0.0+").is_err());
}
}