Skip to main content

atomcode_core/
version_check.rs

1//! Passive "new version available" check.
2//!
3//! At startup, atomcode GETs `latest.json` (the same manifest `/upgrade`
4//! consumes) and, if the advertised version is strictly newer than what's
5//! compiled in, surfaces a right-aligned hint on the input-box status
6//! row. Any error (network, parse, non-matching format) silently returns
7//! `None` — this feature must never be noisy.
8
9use crate::self_update::{Manifest, MANIFEST_URL};
10
11/// Compare a `latest.json` body against the current compiled-in version.
12///
13/// `current` is expected in `vMAJOR.MINOR.PATCH` form; `body` is the raw
14/// JSON manifest. Returns `Some(latest)` only when the body deserializes
15/// cleanly AND its `version` is strictly greater than `current`. Returns
16/// `None` for same-or-older versions, malformed JSON, HTML responses, or
17/// any other noise.
18pub fn parse_and_compare(current: &str, body: &str) -> Option<String> {
19    let manifest: Manifest = serde_json::from_str(body).ok()?;
20    let latest = parse_version_line(&manifest.version)?;
21    let current = parse_version_line(current)?;
22    if latest > current {
23        Some(format_version(latest))
24    } else {
25        None
26    }
27}
28
29fn parse_version_line(s: &str) -> Option<(u64, u64, u64)> {
30    let trimmed = s.trim();
31    if trimmed.len() > 32 {
32        return None;
33    }
34    let rest = trimmed.strip_prefix('v')?;
35    let mut parts = rest.split('.');
36    let major = parts.next()?.parse::<u64>().ok()?;
37    let minor = parts.next()?.parse::<u64>().ok()?;
38    let patch = parts.next()?.parse::<u64>().ok()?;
39    if parts.next().is_some() {
40        return None;
41    }
42    Some((major, minor, patch))
43}
44
45fn format_version(v: (u64, u64, u64)) -> String {
46    format!("v{}.{}.{}", v.0, v.1, v.2)
47}
48
49/// Fetch `latest.json` and, if newer than `current`, return the advertised
50/// version. Short timeout keeps startup snappy; any error (network, HTTP,
51/// parse) returns `None` silently.
52pub async fn check_latest(current: &str) -> Option<String> {
53    let client = reqwest::Client::builder()
54        .timeout(std::time::Duration::from_secs(5))
55        .user_agent(crate::ATOMCODE_USER_AGENT)
56        .build()
57        .ok()?;
58    let resp = client.get(MANIFEST_URL).send().await.ok()?;
59    if !resp.status().is_success() {
60        return None;
61    }
62    let body = resp.text().await.ok()?;
63    parse_and_compare(current, &body)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn manifest_body(version: &str) -> String {
71        format!(
72            r#"{{"version":"{}","binaries":{{"darwin-arm64":{{"sha256":"abcd","size":1024}}}}}}"#,
73            version
74        )
75    }
76
77    #[test]
78    fn newer_patch_version_returns_some() {
79        assert_eq!(
80            parse_and_compare("v4.15.3", &manifest_body("v4.15.4")),
81            Some("v4.15.4".to_string())
82        );
83    }
84
85    #[test]
86    fn newer_minor_version_returns_some() {
87        assert_eq!(
88            parse_and_compare("v4.15.3", &manifest_body("v4.16.0")),
89            Some("v4.16.0".to_string())
90        );
91    }
92
93    #[test]
94    fn newer_major_version_returns_some() {
95        assert_eq!(
96            parse_and_compare("v4.15.3", &manifest_body("v5.0.0")),
97            Some("v5.0.0".to_string())
98        );
99    }
100
101    #[test]
102    fn same_version_returns_none() {
103        assert_eq!(
104            parse_and_compare("v4.15.3", &manifest_body("v4.15.3")),
105            None
106        );
107    }
108
109    #[test]
110    fn older_version_returns_none() {
111        assert_eq!(
112            parse_and_compare("v4.15.3", &manifest_body("v4.15.2")),
113            None
114        );
115        assert_eq!(
116            parse_and_compare("v4.15.3", &manifest_body("v4.14.99")),
117            None
118        );
119        assert_eq!(
120            parse_and_compare("v4.15.3", &manifest_body("v3.99.99")),
121            None
122        );
123    }
124
125    #[test]
126    fn html_body_returns_none() {
127        assert_eq!(parse_and_compare("v4.15.3", "<html>404</html>"), None);
128    }
129
130    #[test]
131    fn empty_body_returns_none() {
132        assert_eq!(parse_and_compare("v4.15.3", ""), None);
133        assert_eq!(parse_and_compare("v4.15.3", "   \n"), None);
134    }
135
136    #[test]
137    fn missing_v_prefix_returns_none() {
138        assert_eq!(parse_and_compare("v4.15.3", &manifest_body("4.15.4")), None);
139    }
140
141    #[test]
142    fn non_numeric_segment_returns_none() {
143        assert_eq!(
144            parse_and_compare("v4.15.3", &manifest_body("v4.15.x")),
145            None
146        );
147        assert_eq!(parse_and_compare("v4.15.3", &manifest_body("vX.Y.Z")), None);
148    }
149
150    #[test]
151    fn too_many_components_returns_none() {
152        assert_eq!(
153            parse_and_compare("v4.15.3", &manifest_body("v4.15.4.1")),
154            None
155        );
156    }
157
158    #[test]
159    fn too_few_components_returns_none() {
160        assert_eq!(parse_and_compare("v4.15.3", &manifest_body("v4.15")), None);
161    }
162
163    #[test]
164    fn missing_version_field_returns_none() {
165        let body = r#"{"binaries":{"darwin-arm64":{"sha256":"abcd","size":1024}}}"#;
166        assert_eq!(parse_and_compare("v4.15.3", body), None);
167    }
168
169    #[test]
170    fn missing_binaries_field_returns_none() {
171        // Manifest requires `binaries`; missing it fails deserialization.
172        let body = r#"{"version":"v4.16.0"}"#;
173        assert_eq!(parse_and_compare("v4.15.3", body), None);
174    }
175
176    #[test]
177    fn trims_whitespace_in_version() {
178        let body = r#"{"version":"  v4.16.0\r\n","binaries":{"darwin-arm64":{"sha256":"abcd","size":1024}}}"#;
179        assert_eq!(
180            parse_and_compare("v4.15.3", body),
181            Some("v4.16.0".to_string())
182        );
183    }
184
185    #[test]
186    fn malformed_current_returns_none() {
187        assert_eq!(parse_and_compare("bad", &manifest_body("v4.16.0")), None);
188    }
189
190    #[test]
191    fn ignores_unknown_json_fields() {
192        let body = r#"{
193            "version": "v4.16.0",
194            "released_at": "2026-04-20T00:00:00Z",
195            "signature": "future-field",
196            "binaries": {"darwin-arm64": {"sha256": "abcd", "size": 1024}}
197        }"#;
198        assert_eq!(
199            parse_and_compare("v4.15.3", body),
200            Some("v4.16.0".to_string())
201        );
202    }
203}