1use bevy::prelude::*;
6use std::time::Duration;
7
8pub struct Where {
22 pub api: String,
23 pub page: String,
24}
25
26pub(super) fn latest_release_urls() -> Option<Where> {
27 let repo = env!("CARGO_PKG_REPOSITORY").strip_prefix("https://github.com/")?;
28 Some(Where {
29 api: format!("https://api.github.com/repos/{repo}/releases/latest"),
30 page: format!("https://github.com/{repo}/releases/latest"),
31 })
32}
33
34const TIMEOUT: Duration = Duration::from_secs(3);
39
40#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
44pub struct Version(semver::Version);
45
46impl Version {
47 pub fn parse(tag: &str) -> Option<Version> {
49 let tag = tag.trim();
50 let tag = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
51 semver::Version::parse(tag).ok().map(Version)
52 }
53
54 pub fn current() -> Version {
56 Version::parse(env!("CARGO_PKG_VERSION"))
59 .unwrap_or_else(|| Version(semver::Version::new(0, 0, 0)))
60 }
61}
62
63impl std::fmt::Display for Version {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 write!(f, "v{}", self.0)
66 }
67}
68
69#[derive(Clone, PartialEq, Eq, Debug)]
71pub struct Release {
72 pub version: Version,
73 pub tag: String,
75 pub url: String,
77 pub notes: String,
79}
80
81fn is_release_page(url: &str) -> bool {
89 url.starts_with("https://github.com/")
90 && url.chars().all(|c| {
91 c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | '/' | ':' | '+')
92 })
93}
94
95pub fn parse_release(json: &str) -> Option<Release> {
98 let value: serde_json::Value = serde_json::from_str(json).ok()?;
99 let tag = value.get("tag_name")?.as_str()?.trim();
100 let version = Version::parse(tag)?;
101 let url = value.get("html_url")?.as_str()?.trim();
102 if !is_release_page(url) {
103 return None;
104 }
105 let notes = value
106 .get("body")
107 .and_then(serde_json::Value::as_str)
108 .unwrap_or("")
109 .to_string();
110 Some(Release {
111 version,
112 tag: tag.to_string(),
113 url: url.to_string(),
114 notes,
115 })
116}
117
118pub fn fetch_latest(urls: &Where) -> Option<Release> {
123 let agent = ureq::Agent::config_builder()
124 .timeout_global(Some(TIMEOUT))
125 .user_agent(concat!("pinch-points/", env!("CARGO_PKG_VERSION")))
126 .max_redirects(0)
129 .build()
130 .new_agent();
131 let release = match ask_api(&agent, &urls.api) {
132 Ok(release) => release,
133 Err(ureq::Error::StatusCode(403 | 429)) => {
136 info!("update check: the API is rationed out; asking the releases page");
137 ask_page(&agent, &urls.page)
138 }
139 Err(e) => {
140 info!("update check: {e}");
141 None
142 }
143 };
144 match &release {
145 Some(release) => info!("update check: newest release is {}", release.tag),
146 None => info!("update check: no release to offer"),
147 }
148 release
149}
150
151fn ask_api(agent: &ureq::Agent, url: &str) -> Result<Option<Release>, ureq::Error> {
154 let body = agent
155 .get(url)
156 .header("Accept", "application/vnd.github+json")
157 .call()?
158 .body_mut()
159 .read_to_string()?;
160 Ok(parse_release(&body))
161}
162
163fn ask_page(agent: &ureq::Agent, url: &str) -> Option<Release> {
165 let response = match agent.get(url).call() {
166 Ok(response) => response,
167 Err(e) => {
168 info!("update check: {e}");
169 return None;
170 }
171 };
172 if !response.status().is_redirection() {
173 info!("update check: the releases page did not redirect");
174 return None;
175 }
176 let location = response.headers().get("location")?.to_str().ok()?;
177 release_from_redirect(location)
178}
179
180pub fn release_from_redirect(location: &str) -> Option<Release> {
183 let location = location.trim();
184 let (_, tag) = location.rsplit_once("/releases/tag/")?;
185 if !is_release_page(location) || tag.is_empty() || tag.contains('/') {
186 return None;
187 }
188 let version = Version::parse(tag)?;
189 Some(Release {
190 version,
191 tag: tag.to_string(),
192 url: location.to_string(),
193 notes: String::new(),
194 })
195}
196
197pub(super) fn demo_release() -> Release {
199 let mut version = Version::current();
200 version.0.minor += 1;
201 version.0.patch = 0;
202 version.0.pre = semver::Prerelease::EMPTY;
203 Release {
204 tag: version.to_string(),
205 url: format!("{}/releases", env!("CARGO_PKG_REPOSITORY")),
206 notes: "## What's new\n\n\
207 - A **new tide event**: the undertow, which drags every loose crab one tile seaward.\n\
208 - Two more Beach Day challenges.\n\
209 - The lobby names the beach that went off the air instead of just dropping it.\n\n\
210 ## Fixes\n\n\
211 - Pairs mode no longer overflows the settings row in German.\n\
212 - See [the changelog](https://example.invalid/changelog) for the rest.\n"
213 .to_string(),
214 version,
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::super::UpdateCheck;
221 use super::super::notes::notes_lines;
222 use super::*;
223
224 #[test]
225 fn versions_parse_and_order() {
226 let v = |s| Version::parse(s);
227 assert_eq!(v("v1.2.3"), Some(Version(semver::Version::new(1, 2, 3))));
228 assert_eq!(v("1.2.3"), v("v1.2.3"));
229 assert_eq!(v(" V1.2.3 "), v("v1.2.3"));
230 assert!(v("v1.2.3-rc.1").is_some());
232 assert!(v("v1.2.3-rc.1") < v("v1.2.3"));
233 assert!(v("v1.2.3-rc.1") > v("v1.2.2"));
234 assert!(v("v1.2.3+build.7").is_some());
235 assert_eq!(v("v1.2"), None);
236 assert_eq!(v("v1.2.3.4"), None);
237 assert_eq!(v("latest"), None);
238 assert_eq!(v(""), None);
239 assert_eq!(v("v1.x.3"), None);
240 assert!(v("v1.10.0") > v("v1.9.9"));
241 assert!(v("v2.0.0") > v("v1.99.99"));
242 assert!(v("v0.1.1") > v("v0.1.0"));
243 assert_eq!(v("v0.1.0").unwrap().to_string(), "v0.1.0");
244 assert_eq!(v("0.2.0-rc.1").unwrap().to_string(), "v0.2.0-rc.1");
245 }
246
247 #[test]
250 fn this_build_has_a_version() {
251 assert_eq!(
252 Some(Version::current()),
253 Version::parse(env!("CARGO_PKG_VERSION"))
254 );
255 assert!(Version::current() > Version::parse("0.0.0").unwrap());
256 }
257
258 #[test]
261 fn the_addresses_name_the_repository() {
262 let urls = latest_release_urls().expect("a GitHub repository");
263 assert_eq!(
264 urls.api,
265 "https://api.github.com/repos/agourlay/pinch-points/releases/latest"
266 );
267 assert_eq!(
268 urls.page,
269 "https://github.com/agourlay/pinch-points/releases/latest"
270 );
271 }
272
273 #[test]
276 fn a_redirect_to_a_tag_is_a_release_without_notes() {
277 let release =
278 release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/v0.3.1")
279 .expect("a release");
280 assert_eq!(release.tag, "v0.3.1");
281 assert_eq!(release.version, Version::parse("0.3.1").unwrap());
282 assert_eq!(release.notes, "");
283 assert!(release.url.ends_with("/releases/tag/v0.3.1"));
284 assert_eq!(
285 release_from_redirect("https://github.com/agourlay/pinch-points/releases"),
286 None
287 );
288 assert_eq!(
289 release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/"),
290 None
291 );
292 assert_eq!(
293 release_from_redirect("https://github.com/x/y/releases/tag/nightly"),
294 None
295 );
296 assert_eq!(
297 release_from_redirect("https://evil.example/x/y/releases/tag/v1.0.0"),
298 None
299 );
300 assert_eq!(
301 release_from_redirect("http://github.com/x/y/releases/tag/v1.0.0"),
302 None
303 );
304 }
305
306 #[test]
310 fn a_github_reply_is_read_as_a_release() {
311 let json = r#"{
312 "url": "https://api.github.com/repos/agourlay/pinch-points/releases/1",
313 "html_url": "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0",
314 "tag_name": "v0.2.0",
315 "name": "Pinch Points 0.2.0",
316 "draft": false,
317 "prerelease": false,
318 "body": "New tide\r\n\r\n- Undertow\r\n",
319 "assets": []
320 }"#;
321 let release = parse_release(json).expect("a release");
322 assert_eq!(release.tag, "v0.2.0");
323 assert_eq!(release.version, Version::parse("0.2.0").unwrap());
324 assert_eq!(
325 release.url,
326 "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0"
327 );
328 assert_eq!(release.notes, "New tide\r\n\r\n- Undertow\r\n");
329
330 let no_body = r#"{"tag_name": "v0.2.0", "html_url": "https://github.com/x/y/releases/tag/v0.2.0", "body": null}"#;
331 assert_eq!(parse_release(no_body).unwrap().notes, "");
332 }
333
334 #[test]
340 fn a_release_page_carries_no_percent() {
341 assert!(is_release_page(
342 "https://github.com/agourlay/pinch-points/releases/tag/v1.2.3"
343 ));
344 assert!(!is_release_page(
345 "https://github.com/agourlay/pinch-points/releases/tag/%41"
346 ));
347 assert!(!is_release_page("http://github.com/x/y"), "https only");
348 assert!(!is_release_page("https://github.com/x/y?a=b"), "no query");
349 }
350
351 #[test]
352 fn what_is_not_a_release() {
353 assert_eq!(
354 parse_release(r#"{"message": "Not Found", "status": "404"}"#),
355 None
356 );
357 assert_eq!(
358 parse_release(r#"{"tag_name": "nightly", "html_url": "https://github.com/x/y"}"#),
359 None
360 );
361 assert_eq!(
362 parse_release(r#"{"tag_name": "v1.0.0", "html_url": "http://github.com/x/y"}"#),
363 None
364 );
365 assert_eq!(
367 parse_release(r#"{"tag_name": "v1.0.0", "html_url": "https://example.com/x"}"#),
368 None
369 );
370 assert_eq!(
371 parse_release(
372 r#"{"tag_name": "v1.0.0", "html_url": "https://github.com/x/y/releases/tag/v1.0.0 & calc"}"#
373 ),
374 None
375 );
376 assert_eq!(
377 parse_release(r#"{"tag_name": "v1.0.0", "html_url": "javascript:alert(1)"}"#),
378 None
379 );
380 assert_eq!(parse_release("<html>rate limited</html>"), None);
381 assert_eq!(parse_release(""), None);
382 }
383
384 #[test]
387 fn the_demo_release_is_offered() {
388 let demo = demo_release();
389 assert!(UpdateCheck::worth_offering(&Version::current(), &demo));
390 assert!(demo.url.starts_with("https://github.com/"));
391 assert!(notes_lines(&demo.notes, 12).len() > 4);
392 }
393}