use semver::Version;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Asset {
pub name: String,
pub url: String,
pub size: u64,
}
impl Asset {
pub fn new(name: impl Into<String>, url: impl Into<String>, size: u64) -> Self {
Self {
name: name.into(),
url: url.into(),
size,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Release {
pub version: Version,
pub notes: Option<String>,
pub asset: Asset,
pub signature: Option<String>,
pub signature_url: Option<String>,
pub sha256: Option<String>,
pub ed25519_signature: Option<String>,
}
impl Release {
pub fn new(version: Version, asset: Asset) -> Self {
Self {
version,
notes: None,
asset,
signature: None,
signature_url: None,
sha256: None,
ed25519_signature: None,
}
}
#[must_use]
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
#[must_use]
pub fn with_sha256(mut self, sha256: impl Into<String>) -> Self {
self.sha256 = Some(sha256.into().trim().to_ascii_lowercase());
self
}
#[must_use]
pub fn with_minisign_signature(mut self, signature: impl Into<String>) -> Self {
self.signature = Some(signature.into());
self
}
#[must_use]
pub fn with_minisign_signature_url(mut self, url: impl Into<String>) -> Self {
self.signature_url = Some(url.into());
self
}
#[must_use]
pub fn with_ed25519_signature(mut self, signature: impl Into<String>) -> Self {
self.ed25519_signature = Some(signature.into());
self
}
}
pub fn parse_tag(tag: &str) -> Result<Version> {
let trimmed = tag.trim();
let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
Version::parse(stripped).map_err(|e| Error::Parse(format!("tag `{tag}`: {e}")))
}
pub fn parse_version_lenient(version: &str) -> Result<Version> {
if let Ok(parsed) = parse_tag(version) {
return Ok(parsed);
}
let trimmed = version.trim();
let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
let parts: Vec<&str> = stripped.split('.').collect();
let numeric = !parts.is_empty()
&& parts.len() <= 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
if !numeric {
return Err(Error::Parse(format!("version `{version}` is not semver")));
}
let mut padded = parts.join(".");
for _ in parts.len()..3 {
padded.push_str(".0");
}
Version::parse(&padded).map_err(|e| Error::Parse(format!("version `{version}`: {e}")))
}
#[cfg(test)]
mod tests {
use super::{Asset, Release, parse_tag, parse_version_lenient};
use semver::Version;
#[test]
fn parses_v_prefixed_and_bare_tags() {
assert_eq!(parse_tag("v1.2.3").unwrap().to_string(), "1.2.3");
assert_eq!(parse_tag(" 1.2.3 ").unwrap().to_string(), "1.2.3");
assert_eq!(parse_tag("v0.1.0-rc.1").unwrap().to_string(), "0.1.0-rc.1");
}
#[test]
fn rejects_non_semver_tags() {
assert!(parse_tag("latest").is_err());
assert!(parse_tag("v1.2").is_err());
}
#[test]
fn lenient_parse_pads_short_numeric_versions() {
assert_eq!(parse_version_lenient("2").unwrap(), Version::new(2, 0, 0));
assert_eq!(
parse_version_lenient("v1.4").unwrap(),
Version::new(1, 4, 0)
);
assert_eq!(
parse_version_lenient(" 1.2.3 ").unwrap(),
Version::new(1, 2, 3)
);
assert_eq!(
parse_version_lenient("0.3.0-beta.2").unwrap().to_string(),
"0.3.0-beta.2"
);
}
#[test]
fn lenient_parse_rejects_what_it_cannot_order() {
for bad in ["", "1.2.3.4", "1..2", "build 42", "1.2b", "latest"] {
assert!(parse_version_lenient(bad).is_err(), "{bad:?} should fail");
}
}
#[test]
fn builder_sets_optional_fields() {
let release = Release::new(
Version::new(1, 0, 0),
Asset::new("app.dmg", "https://example.test/app.dmg", 3),
)
.with_notes("notes")
.with_sha256(" ABC ")
.with_ed25519_signature("sig");
assert_eq!(release.notes.as_deref(), Some("notes"));
assert_eq!(release.sha256.as_deref(), Some("abc"));
assert_eq!(release.ed25519_signature.as_deref(), Some("sig"));
assert!(release.signature.is_none());
assert_eq!(release.asset.size, 3);
}
}