use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?(?:-([a-zA-Z]+)(\d+)?)?(.*?)$")
.unwrap()
});
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GodotVersion {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
pub sub_patch: Option<u32>,
pub release_tag: Option<String>,
pub tag_version: Option<u32>,
pub extra: Option<String>,
pub is_dotnet: bool,
}
impl GodotVersion {
pub fn new(version_str: &str, is_dotnet: bool) -> Result<Self> {
let caps = VERSION_REGEX
.captures(version_str)
.context("Invalid Godot version format")?;
let major = caps
.get(1)
.context("Invalid major version")?
.as_str()
.parse()
.context("Invalid major version")?;
let minor_opt = caps
.get(2)
.map(|m| m.as_str().parse())
.transpose()
.context("Invalid minor version")?;
let patch_opt = caps
.get(3)
.map(|m| m.as_str().parse())
.transpose()
.context("Invalid patch version")?;
let sub_patch_opt = caps
.get(4)
.map(|m| m.as_str().parse())
.transpose()
.context("Invalid sub-patch version")?;
let release_tag = Some(
caps.get(5)
.map_or("stable".to_string(), |m| m.as_str().to_string()),
);
let tag_version = caps
.get(6)
.map(|m| m.as_str().parse())
.transpose()
.context("Invalid tag version")?;
let extra = caps
.get(7)
.map(|m| m.as_str().to_string())
.filter(|s| !s.is_empty());
let sub_patch = sub_patch_opt.filter(|value| *value > 0);
let patch = patch_opt.filter(|value| sub_patch.is_some() || *value > 0);
let minor = minor_opt.filter(|value| patch.is_some() || *value > 0);
Ok(GodotVersion {
major,
minor,
patch,
sub_patch,
release_tag,
tag_version,
extra,
is_dotnet,
})
}
pub fn as_godot_version_str(&self) -> String {
let mut out = self.as_str_no_release_tag();
if let Some(release_tag) = &self.release_tag {
out.push_str(&format!("-{}", release_tag));
}
if let Some(tag_v) = self.tag_version {
out.push_str(&tag_v.to_string());
}
if let Some(extra) = &self.extra {
out.push_str(extra);
}
out
}
pub fn as_str_no_release_tag(&self) -> String {
let mut out = format!("{}.{}", self.major, self.minor.unwrap_or(0));
if let Some(patch) = self.patch {
out.push_str(&format!(".{}", patch));
if let Some(sub_patch) = self.sub_patch {
out.push_str(&format!(".{}", sub_patch));
}
}
out
}
#[allow(dead_code)]
pub fn is_prerelease(&self) -> bool {
self.release_tag.as_ref().is_none_or(|tag| tag != "stable")
}
}
impl PartialOrd for GodotVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for GodotVersion {
fn cmp(&self, other: &Self) -> Ordering {
fn get_tag_rank(tag: &Option<String>) -> u32 {
match tag
.clone()
.unwrap_or("stable".to_string())
.to_lowercase()
.as_str()
{
"stable" => 100,
"rc" => 80,
"beta" => 60,
"alpha" => 40,
"dev" => 20,
_ => 0, }
}
self.major
.cmp(&other.major)
.then(self.minor.unwrap_or(0).cmp(&other.minor.unwrap_or(0)))
.then(self.patch.unwrap_or(0).cmp(&other.patch.unwrap_or(0)))
.then(
self.sub_patch
.unwrap_or(0)
.cmp(&other.sub_patch.unwrap_or(0)),
)
.then(get_tag_rank(&self.release_tag).cmp(&get_tag_rank(&other.release_tag)))
.then(self.release_tag.cmp(&other.release_tag)) .then(
self.tag_version
.unwrap_or(0)
.cmp(&other.tag_version.unwrap_or(0)),
)
.then(self.is_dotnet.cmp(&other.is_dotnet))
.then(self.extra.cmp(&other.extra))
}
}
impl FromStr for GodotVersion {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s, false)
}
}
impl fmt::Display for GodotVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_dotnet {
write!(f, "{} (.NET)", self.as_godot_version_str())
} else {
write!(f, "{}", self.as_godot_version_str())
}
}
}
pub fn version_buffet(all_releases: &[GodotVersion]) -> Vec<&GodotVersion> {
let mut most_recent_top: Vec<&GodotVersion> = all_releases
.iter()
.filter(|v| v.major >= 3 && !v.is_dotnet)
.rev()
.collect();
most_recent_top.dedup_by(|a, b| {
a.minor == b.minor && b.release_tag.as_ref().is_some_and(|tag| tag == "stable")
});
let max_major_version = most_recent_top
.iter()
.max_by_key(|v| v.major)
.map(|v| v.major)
.unwrap_or(0);
(3..=max_major_version) .flat_map(|major| {
most_recent_top
.iter()
.filter(|r| r.major == major)
.take(5)
.copied()
.collect::<Vec<_>>()
.into_iter()
.rev()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
#[test]
fn test_version_parsing() -> Result<()> {
let v1 = GodotVersion::new("4.2.1", false)?;
assert_eq!(v1.as_godot_version_str(), "4.2.1-stable");
assert!(!v1.is_prerelease());
let v2 = GodotVersion::new("4.2.1-stable", false)?;
assert_eq!(v2.as_godot_version_str(), "4.2.1-stable");
assert!(!v2.is_prerelease());
let v3 = GodotVersion::new("4.3.0-beta2", false)?;
assert_eq!(v3.as_godot_version_str(), "4.3-beta2");
assert!(v3.is_prerelease());
let v4 = GodotVersion::new("4.1.0-rc.1", false)?;
assert_eq!(v4.as_godot_version_str(), "4.1-rc.1");
assert!(v4.is_prerelease());
let v7 = GodotVersion::new("4.3.0.1", false)?;
assert_eq!(v7.as_godot_version_str(), "4.3.0.1-stable");
assert_eq!(v7.major, 4);
assert_eq!(v7.minor, Some(3));
assert_eq!(v7.patch, Some(0));
assert_eq!(v7.sub_patch, Some(1));
let v8 = GodotVersion::new("4.4.stable.official.8981fd6c1", false)?;
assert_eq!(
v8.as_godot_version_str(),
"4.4-stable.stable.official.8981fd6c1"
);
assert_eq!(v8.extra, Some(".stable.official.8981fd6c1".to_string()));
let v6 = GodotVersion::new("4.5-beta1", false)?;
assert_eq!(v6.as_godot_version_str(), "4.5-beta1");
assert!(v6.is_prerelease());
Ok(())
}
#[test]
fn test_version_comparison() -> Result<()> {
let v1 = GodotVersion::new("4.2", false)?;
let v2 = GodotVersion::new("4.2.0-stable", false)?;
let v3 = GodotVersion::new("4.2.1-rc5", false)?;
let v4 = GodotVersion::new("4.2.1-rc5", false)?;
assert!(v1.cmp(&v2).is_eq());
assert!(v2.cmp(&v3).is_lt());
assert!(v3.cmp(&v4).is_eq());
Ok(())
}
#[test]
fn test_version_buffet() -> Result<()> {
let releases: Vec<GodotVersion> = [
"4.5-beta7",
"4.5-rc1",
"4.5-rc2",
"4.5-stable",
"4.5.1-rc1",
"4.5.1-rc2",
"4.5.1-stable",
"4.5.2-rc1",
"4.6-dev1",
"4.6-dev2",
"4.6-dev3",
"4.6-dev4",
"4.6-dev5",
"4.6-dev6",
"4.6-beta1",
"4.6-beta2",
"4.6-beta3",
"4.6-rc1",
"4.6-rc2",
"4.6-stable",
"4.6.1-rc1",
"4.7-dev1",
]
.iter()
.flat_map(|s| [GodotVersion::new(s, false), GodotVersion::new(s, true)])
.collect::<Result<Vec<_>>>()?;
let expected: Vec<GodotVersion> = [
"4.5.1-stable",
"4.5.2-rc1",
"4.6-stable",
"4.6.1-rc1",
"4.7-dev1",
]
.iter()
.map(|s| GodotVersion::new(s, false))
.collect::<Result<Vec<_>>>()?;
let buffet = version_buffet(&releases);
tracing::info!(
"{:#?}",
buffet.iter().map(|v| v.to_string()).collect::<Vec<_>>()
);
assert_eq!(buffet.len(), 5);
buffet
.iter()
.zip(expected)
.for_each(|(v1, v2)| assert_eq!(**v1, v2));
Ok(())
}
}