use bevy::prelude::*;
use std::time::Duration;
pub struct Where {
pub api: String,
pub page: String,
}
pub(super) fn latest_release_urls() -> Option<Where> {
let repo = env!("CARGO_PKG_REPOSITORY").strip_prefix("https://github.com/")?;
Some(Where {
api: format!("https://api.github.com/repos/{repo}/releases/latest"),
page: format!("https://github.com/{repo}/releases/latest"),
})
}
const TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Clone, Debug)]
pub struct Version(semver::Version);
impl PartialEq for Version {
fn eq(&self, other: &Self) -> bool {
self.cmp(other).is_eq()
}
}
impl Eq for Version {}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp_precedence(&other.0)
}
}
impl Version {
pub fn parse(tag: &str) -> Option<Version> {
let tag = tag.trim();
let tag = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
semver::Version::parse(tag).ok().map(Version)
}
pub fn current() -> Version {
Version::parse(env!("CARGO_PKG_VERSION"))
.unwrap_or_else(|| Version(semver::Version::new(0, 0, 0)))
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Release {
pub version: Version,
pub tag: String,
pub url: String,
pub notes: String,
}
fn is_release_page(url: &str) -> bool {
url.starts_with("https://github.com/")
&& url.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | '/' | ':' | '+')
})
}
pub fn parse_release(json: &str) -> Option<Release> {
let value: serde_json::Value = serde_json::from_str(json).ok()?;
let tag = value.get("tag_name")?.as_str()?.trim();
let version = Version::parse(tag)?;
let url = value.get("html_url")?.as_str()?.trim();
if !is_release_page(url) {
return None;
}
let notes = value
.get("body")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
Some(Release {
version,
tag: tag.to_string(),
url: url.to_string(),
notes,
})
}
pub fn fetch_latest(urls: &Where) -> Option<Release> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.user_agent(concat!("pinch-points/", env!("CARGO_PKG_VERSION")))
.max_redirects(0)
.build()
.new_agent();
let release = match ask_api(&agent, &urls.api) {
Ok(release) => release,
Err(ureq::Error::StatusCode(403 | 429)) => {
info!("update check: the API is rationed out; asking the releases page");
ask_page(&agent, &urls.page)
}
Err(e) => {
info!("update check: {e}");
None
}
};
match &release {
Some(release) => info!("update check: newest release is {}", release.tag),
None => info!("update check: no release to offer"),
}
release
}
fn ask_api(agent: &ureq::Agent, url: &str) -> Result<Option<Release>, ureq::Error> {
let body = agent
.get(url)
.header("Accept", "application/vnd.github+json")
.call()?
.body_mut()
.read_to_string()?;
Ok(parse_release(&body))
}
fn ask_page(agent: &ureq::Agent, url: &str) -> Option<Release> {
let response = match agent.get(url).call() {
Ok(response) => response,
Err(e) => {
info!("update check: {e}");
return None;
}
};
if !response.status().is_redirection() {
info!("update check: the releases page did not redirect");
return None;
}
let location = response.headers().get("location")?.to_str().ok()?;
release_from_redirect(location)
}
pub fn release_from_redirect(location: &str) -> Option<Release> {
let location = location.trim();
let (_, tag) = location.rsplit_once("/releases/tag/")?;
if !is_release_page(location) || tag.is_empty() || tag.contains('/') {
return None;
}
let version = Version::parse(tag)?;
Some(Release {
version,
tag: tag.to_string(),
url: location.to_string(),
notes: String::new(),
})
}
pub(super) fn demo_release() -> Release {
let mut version = Version::current();
version.0.minor += 1;
version.0.patch = 0;
version.0.pre = semver::Prerelease::EMPTY;
Release {
tag: version.to_string(),
url: format!("{}/releases", env!("CARGO_PKG_REPOSITORY")),
notes: "## What's new\n\n\
- A **new tide event**: the undertow, which drags every loose crab one tile seaward.\n\
- Two more Beach Day challenges.\n\
- The lobby names the beach that went off the air instead of just dropping it.\n\n\
## Fixes\n\n\
- Pairs mode no longer overflows the settings row in German.\n\
- See [the changelog](https://example.invalid/changelog) for the rest.\n"
.to_string(),
version,
}
}
#[cfg(test)]
mod tests {
use super::super::UpdateCheck;
use super::super::notes::notes_lines;
use super::*;
#[test]
fn versions_parse_and_order() {
let v = |s| Version::parse(s);
assert_eq!(v("v1.2.3"), Some(Version(semver::Version::new(1, 2, 3))));
assert_eq!(v("1.2.3"), v("v1.2.3"));
assert_eq!(v(" V1.2.3 "), v("v1.2.3"));
assert!(v("v1.2.3-rc.1").is_some());
assert!(v("v1.2.3-rc.1") < v("v1.2.3"));
assert!(v("v1.2.3-rc.1") > v("v1.2.2"));
assert!(v("v1.2.3+build.7").is_some());
assert_eq!(v("v1.2.3+build.7"), v("v1.2.3"));
assert!(v("v1.2.3+linux") <= v("1.2.3"), "not newer than itself");
assert_eq!(v("v1.2"), None);
assert_eq!(v("v1.2.3.4"), None);
assert_eq!(v("latest"), None);
assert_eq!(v(""), None);
assert_eq!(v("v1.x.3"), None);
assert!(v("v1.10.0") > v("v1.9.9"));
assert!(v("v2.0.0") > v("v1.99.99"));
assert!(v("v0.1.1") > v("v0.1.0"));
assert_eq!(v("v0.1.0").unwrap().to_string(), "v0.1.0");
assert_eq!(v("0.2.0-rc.1").unwrap().to_string(), "v0.2.0-rc.1");
}
#[test]
fn this_build_has_a_version() {
assert_eq!(
Some(Version::current()),
Version::parse(env!("CARGO_PKG_VERSION"))
);
assert!(Version::current() > Version::parse("0.0.0").unwrap());
}
#[test]
fn the_addresses_name_the_repository() {
let urls = latest_release_urls().expect("a GitHub repository");
assert_eq!(
urls.api,
"https://api.github.com/repos/agourlay/pinch-points/releases/latest"
);
assert_eq!(
urls.page,
"https://github.com/agourlay/pinch-points/releases/latest"
);
}
#[test]
fn a_redirect_to_a_tag_is_a_release_without_notes() {
let release =
release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/v0.3.1")
.expect("a release");
assert_eq!(release.tag, "v0.3.1");
assert_eq!(release.version, Version::parse("0.3.1").unwrap());
assert_eq!(release.notes, "");
assert!(release.url.ends_with("/releases/tag/v0.3.1"));
assert_eq!(
release_from_redirect("https://github.com/agourlay/pinch-points/releases"),
None
);
assert_eq!(
release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/"),
None
);
assert_eq!(
release_from_redirect("https://github.com/x/y/releases/tag/nightly"),
None
);
assert_eq!(
release_from_redirect("https://evil.example/x/y/releases/tag/v1.0.0"),
None
);
assert_eq!(
release_from_redirect("http://github.com/x/y/releases/tag/v1.0.0"),
None
);
}
#[test]
fn a_github_reply_is_read_as_a_release() {
let json = r#"{
"url": "https://api.github.com/repos/agourlay/pinch-points/releases/1",
"html_url": "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0",
"tag_name": "v0.2.0",
"name": "Pinch Points 0.2.0",
"draft": false,
"prerelease": false,
"body": "New tide\r\n\r\n- Undertow\r\n",
"assets": []
}"#;
let release = parse_release(json).expect("a release");
assert_eq!(release.tag, "v0.2.0");
assert_eq!(release.version, Version::parse("0.2.0").unwrap());
assert_eq!(
release.url,
"https://github.com/agourlay/pinch-points/releases/tag/v0.2.0"
);
assert_eq!(release.notes, "New tide\r\n\r\n- Undertow\r\n");
let no_body = r#"{"tag_name": "v0.2.0", "html_url": "https://github.com/x/y/releases/tag/v0.2.0", "body": null}"#;
assert_eq!(parse_release(no_body).unwrap().notes, "");
}
#[test]
fn a_release_page_carries_no_percent() {
assert!(is_release_page(
"https://github.com/agourlay/pinch-points/releases/tag/v1.2.3"
));
assert!(!is_release_page(
"https://github.com/agourlay/pinch-points/releases/tag/%41"
));
assert!(!is_release_page("http://github.com/x/y"), "https only");
assert!(!is_release_page("https://github.com/x/y?a=b"), "no query");
}
#[test]
fn what_is_not_a_release() {
assert_eq!(
parse_release(r#"{"message": "Not Found", "status": "404"}"#),
None
);
assert_eq!(
parse_release(r#"{"tag_name": "nightly", "html_url": "https://github.com/x/y"}"#),
None
);
assert_eq!(
parse_release(r#"{"tag_name": "v1.0.0", "html_url": "http://github.com/x/y"}"#),
None
);
assert_eq!(
parse_release(r#"{"tag_name": "v1.0.0", "html_url": "https://example.com/x"}"#),
None
);
assert_eq!(
parse_release(
r#"{"tag_name": "v1.0.0", "html_url": "https://github.com/x/y/releases/tag/v1.0.0 & calc"}"#
),
None
);
assert_eq!(
parse_release(r#"{"tag_name": "v1.0.0", "html_url": "javascript:alert(1)"}"#),
None
);
assert_eq!(parse_release("<html>rate limited</html>"), None);
assert_eq!(parse_release(""), None);
}
#[test]
fn the_demo_release_is_offered() {
let demo = demo_release();
assert!(UpdateCheck::worth_offering(&Version::current(), &demo));
assert!(demo.url.starts_with("https://github.com/"));
assert!(notes_lines(&demo.notes, 12).len() > 4);
}
}