Skip to main content

entracte_lib/
updater.rs

1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4use tauri::AppHandle;
5
6const RELEASES_URL: &str = "https://api.github.com/repos/drmowinckels/entracte/releases/latest";
7
8/// Result of checking GitHub Releases for a newer Entracte build.
9///
10/// `has_update` is true when `latest` has a strictly greater SemVer
11/// precedence than `current`. `release_url` points at the GitHub
12/// release page so the renderer can deep-link the user.
13#[derive(Debug, Clone, Serialize)]
14pub struct UpdateInfo {
15    pub current: String,
16    pub latest: String,
17    pub has_update: bool,
18    pub release_url: String,
19}
20
21#[derive(Deserialize)]
22struct GhRelease {
23    tag_name: String,
24    html_url: String,
25}
26
27// Parsed shape: (numeric core, optional pre-release tag).
28// Pre-release tags (anything after `-`) sort BEFORE the same numeric core
29// without one — `1.2.3-rc1 < 1.2.3` — matching SemVer §11.
30type ParsedVersion = (Vec<u32>, Option<String>);
31
32fn parse_version(version: &str) -> ParsedVersion {
33    // Strip build metadata (`+...`) — it has no precedence per SemVer §10.
34    let body = version
35        .trim()
36        .trim_start_matches('v')
37        .split('+')
38        .next()
39        .unwrap_or("");
40    let (core, pre) = match body.split_once('-') {
41        Some((core, suffix)) if !suffix.is_empty() => (core, Some(suffix.to_string())),
42        _ => (body, None),
43    };
44    let nums: Vec<u32> = core
45        .split('.')
46        .filter(|p| !p.is_empty())
47        .filter_map(|p| p.parse().ok())
48        .collect();
49    (nums, pre)
50}
51
52fn is_newer(latest: &str, current: &str) -> bool {
53    let (lnum, lpre) = parse_version(latest);
54    let (cnum, cpre) = parse_version(current);
55    match lnum.cmp(&cnum) {
56        std::cmp::Ordering::Greater => true,
57        std::cmp::Ordering::Less => false,
58        std::cmp::Ordering::Equal => match (lpre, cpre) {
59            (None, None) => false,
60            // Same numeric core: pre-release < release.
61            (Some(_), None) => false,
62            (None, Some(_)) => true,
63            (Some(a), Some(b)) => a > b,
64        },
65    }
66}
67
68fn normalize(version: &str) -> String {
69    version.trim_start_matches('v').to_string()
70}
71
72/// Hit the GitHub Releases API and compare the latest tag against
73/// the running version. 10-second timeout; errors stringify the
74/// underlying reqwest / parse failure for display in the About tab.
75#[tauri::command]
76pub async fn check_for_update(app: AppHandle) -> Result<UpdateInfo, String> {
77    let current = app.package_info().version.to_string();
78    check_for_update_at(RELEASES_URL, &current).await
79}
80
81/// HTTP layer for `check_for_update`. Split off so tests can point it
82/// at a `mockito::Server` URL without bringing up a Tauri `AppHandle`.
83pub(crate) async fn check_for_update_at(url: &str, current: &str) -> Result<UpdateInfo, String> {
84    let client = reqwest::Client::builder()
85        .user_agent(format!("entracte/{current}"))
86        .timeout(Duration::from_secs(10))
87        .build()
88        .map_err(|e| e.to_string())?;
89
90    let release: GhRelease = client
91        .get(url)
92        .header("Accept", "application/vnd.github+json")
93        .send()
94        .await
95        .map_err(|e| e.to_string())?
96        .error_for_status()
97        .map_err(|e| e.to_string())?
98        .json()
99        .await
100        .map_err(|e| e.to_string())?;
101
102    Ok(UpdateInfo {
103        has_update: is_newer(&release.tag_name, current),
104        current: current.to_string(),
105        latest: normalize(&release.tag_name),
106        release_url: release.html_url,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn parse_version_strips_v_prefix() {
116        assert_eq!(parse_version("v0.1.0"), (vec![0, 1, 0], None));
117        assert_eq!(parse_version("0.1.0"), (vec![0, 1, 0], None));
118    }
119
120    #[test]
121    fn parse_version_separates_pre_release_tag() {
122        assert_eq!(
123            parse_version("v1.2.3-beta.4"),
124            (vec![1, 2, 3], Some("beta.4".to_string()))
125        );
126        assert_eq!(
127            parse_version("1.2.3-rc1"),
128            (vec![1, 2, 3], Some("rc1".to_string()))
129        );
130    }
131
132    #[test]
133    fn parse_version_strips_build_metadata() {
134        assert_eq!(parse_version("1.2.3+build.7"), (vec![1, 2, 3], None));
135        assert_eq!(
136            parse_version("1.2.3-rc1+build.7"),
137            (vec![1, 2, 3], Some("rc1".to_string()))
138        );
139    }
140
141    #[test]
142    fn is_newer_major_minor_patch() {
143        assert!(is_newer("v0.1.0", "0.0.1"));
144        assert!(is_newer("v1.0.0", "0.99.99"));
145        assert!(is_newer("v0.0.2", "0.0.1"));
146        assert!(!is_newer("v0.0.1", "0.0.1"));
147        assert!(!is_newer("v0.0.1", "0.0.2"));
148    }
149
150    #[test]
151    fn is_newer_handles_v_prefix_either_side() {
152        assert!(is_newer("v0.1.0", "v0.0.9"));
153        assert!(!is_newer("0.0.9", "v0.0.9"));
154    }
155
156    #[test]
157    fn is_newer_pre_release_is_older_than_same_stable() {
158        // Pre-fix this falsely returned true because parts("v1.2.3-beta.4")
159        // collected [1,2,3,4] which is lex-greater than [1,2,3].
160        assert!(!is_newer("v1.2.3-beta.4", "1.2.3"));
161        assert!(!is_newer("v0.2.0-rc1", "0.2.0"));
162    }
163
164    #[test]
165    fn is_newer_stable_beats_same_core_pre_release() {
166        assert!(is_newer("v1.2.3", "1.2.3-beta.4"));
167        assert!(is_newer("v0.2.0", "0.2.0-rc1"));
168    }
169
170    #[test]
171    fn is_newer_compares_pre_release_tags_lexically() {
172        assert!(is_newer("v1.2.3-rc2", "1.2.3-rc1"));
173        assert!(!is_newer("v1.2.3-rc1", "1.2.3-rc2"));
174        assert!(!is_newer("v1.2.3-rc1", "1.2.3-rc1"));
175    }
176
177    #[test]
178    fn is_newer_higher_core_beats_lower_core_with_pre_release() {
179        assert!(is_newer("v1.3.0-rc1", "1.2.3"));
180        assert!(!is_newer("v1.2.3", "1.3.0-rc1"));
181    }
182
183    #[test]
184    fn normalize_strips_v() {
185        assert_eq!(normalize("v0.1.0"), "0.1.0");
186        assert_eq!(normalize("0.1.0"), "0.1.0");
187    }
188
189    // HTTP-layer coverage. mockito stands in for the GitHub Releases
190    // API so each test pins both the response body and the status code
191    // without touching the network.
192
193    fn body_for(tag: &str) -> String {
194        serde_json::json!({
195            "tag_name": tag,
196            "html_url": format!("https://example.test/release/{tag}"),
197        })
198        .to_string()
199    }
200
201    #[tokio::test]
202    async fn check_for_update_returns_has_update_when_tag_is_newer() {
203        let mut server = mockito::Server::new_async().await;
204        let _m = server
205            .mock("GET", "/")
206            .with_status(200)
207            .with_header("content-type", "application/json")
208            .with_body(body_for("v9.9.9"))
209            .create_async()
210            .await;
211        let url = server.url();
212        let info = check_for_update_at(&url, "0.1.0").await.unwrap();
213        assert!(info.has_update, "expected newer tag to report has_update");
214        assert_eq!(info.current, "0.1.0");
215        assert_eq!(info.latest, "9.9.9");
216        assert_eq!(info.release_url, "https://example.test/release/v9.9.9");
217    }
218
219    #[tokio::test]
220    async fn check_for_update_returns_no_update_when_tag_matches_current() {
221        let mut server = mockito::Server::new_async().await;
222        let _m = server
223            .mock("GET", "/")
224            .with_status(200)
225            .with_header("content-type", "application/json")
226            .with_body(body_for("v0.1.0"))
227            .create_async()
228            .await;
229        let url = server.url();
230        let info = check_for_update_at(&url, "0.1.0").await.unwrap();
231        assert!(!info.has_update);
232        assert_eq!(info.latest, "0.1.0");
233    }
234
235    #[tokio::test]
236    async fn check_for_update_returns_no_update_when_tag_is_older() {
237        let mut server = mockito::Server::new_async().await;
238        let _m = server
239            .mock("GET", "/")
240            .with_status(200)
241            .with_header("content-type", "application/json")
242            .with_body(body_for("v0.0.1"))
243            .create_async()
244            .await;
245        let url = server.url();
246        let info = check_for_update_at(&url, "0.1.0").await.unwrap();
247        assert!(!info.has_update);
248    }
249
250    #[tokio::test]
251    async fn check_for_update_returns_err_on_404() {
252        let mut server = mockito::Server::new_async().await;
253        let _m = server
254            .mock("GET", "/")
255            .with_status(404)
256            .with_body("not found")
257            .create_async()
258            .await;
259        let url = server.url();
260        let err = check_for_update_at(&url, "0.1.0")
261            .await
262            .expect_err("404 should surface as Err");
263        assert!(!err.is_empty(), "error must carry a message");
264    }
265
266    #[tokio::test]
267    async fn check_for_update_returns_err_on_500() {
268        let mut server = mockito::Server::new_async().await;
269        let _m = server
270            .mock("GET", "/")
271            .with_status(500)
272            .with_body("server error")
273            .create_async()
274            .await;
275        let url = server.url();
276        let err = check_for_update_at(&url, "0.1.0")
277            .await
278            .expect_err("500 should surface as Err");
279        assert!(!err.is_empty());
280    }
281
282    #[tokio::test]
283    async fn check_for_update_returns_err_on_malformed_json() {
284        let mut server = mockito::Server::new_async().await;
285        let _m = server
286            .mock("GET", "/")
287            .with_status(200)
288            .with_header("content-type", "application/json")
289            .with_body("{not valid json")
290            .create_async()
291            .await;
292        let url = server.url();
293        let err = check_for_update_at(&url, "0.1.0")
294            .await
295            .expect_err("malformed JSON should surface as Err");
296        assert!(!err.is_empty());
297    }
298
299    #[tokio::test]
300    async fn check_for_update_returns_err_when_url_is_unreachable() {
301        // Bind a TCP socket and immediately drop it — port is free, so
302        // any subsequent connect attempt is refused. Cheaper and more
303        // reliable than waiting on a real timeout.
304        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
305        let port = listener.local_addr().unwrap().port();
306        drop(listener);
307        let url = format!("http://127.0.0.1:{port}/");
308        let err = check_for_update_at(&url, "0.1.0")
309            .await
310            .expect_err("connection refused must surface as Err");
311        assert!(!err.is_empty());
312    }
313}