smriti/services/
update_checker.rs1use semver::Version;
12use serde::Deserialize;
13use std::time::Duration;
14
15const DEFAULT_API_URL: &str =
19 "https://api.github.com/repos/ChivukulaVirinchi/photovault/releases/latest";
20
21const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
24
25#[derive(Debug, Clone, Deserialize)]
28pub struct ReleaseAsset {
29 pub name: String,
30 pub browser_download_url: String,
31 #[serde(rename = "size")]
32 pub size_bytes: u64,
33}
34
35#[derive(Debug, Deserialize)]
38struct GithubRelease {
39 tag_name: String,
40 html_url: String,
41 #[serde(default)]
42 body: String,
43 #[serde(default)]
44 prerelease: bool,
45 #[serde(default)]
46 assets: Vec<ReleaseAsset>,
47}
48
49#[derive(Debug, Clone)]
51#[allow(dead_code)] pub struct LatestRelease {
55 pub version: Version,
56 pub tag_name: String,
57 pub html_url: String,
58 pub body: String,
59 pub assets: Vec<ReleaseAsset>,
60 pub is_prerelease: bool,
61}
62
63#[derive(Debug, Clone)]
66pub struct UpdateStatus {
67 pub current: Version,
68 pub latest: LatestRelease,
69 pub newer_available: bool,
70}
71
72pub fn current_version() -> Version {
73 Version::parse(env!("CARGO_PKG_VERSION"))
74 .expect("CARGO_PKG_VERSION must be a valid semantic version")
75}
76
77#[derive(Debug, thiserror::Error)]
80pub enum UpdateCheckError {
81 #[error("failed to reach GitHub's release API: {0}")]
82 Network(String),
83
84 #[error("GitHub returned HTTP {status} from {url}")]
85 HttpStatus { status: u16, url: String },
86
87 #[error("failed to parse GitHub's release JSON: {0}")]
88 Parse(String),
89
90 #[error("couldn't parse the release's tag as a semver: {0}")]
91 InvalidTag(String),
92
93 #[error("couldn't parse Smriti's own version as a semver: {0}")]
94 InvalidSelfVersion(String),
95}
96
97pub async fn check_for_updates() -> Result<UpdateStatus, UpdateCheckError> {
104 let url = std::env::var("SMRITI_GITHUB_API_URL")
105 .or_else(|_| std::env::var("PHOTOVAULT_GITHUB_API_URL"))
106 .unwrap_or_else(|_| DEFAULT_API_URL.to_string());
107
108 let client = reqwest::Client::builder()
109 .timeout(REQUEST_TIMEOUT)
110 .user_agent(format!("smriti/{}", env!("CARGO_PKG_VERSION")))
111 .build()
112 .map_err(|e| UpdateCheckError::Network(e.to_string()))?;
113
114 let response = client
115 .get(&url)
116 .header("Accept", "application/vnd.github+json")
117 .header("X-GitHub-Api-Version", "2022-11-28")
118 .send()
119 .await
120 .map_err(|e| UpdateCheckError::Network(e.to_string()))?;
121
122 let status = response.status();
123 if !status.is_success() {
124 return Err(UpdateCheckError::HttpStatus {
125 status: status.as_u16(),
126 url,
127 });
128 }
129
130 let payload: GithubRelease = response
131 .json()
132 .await
133 .map_err(|e| UpdateCheckError::Parse(e.to_string()))?;
134
135 let latest_version = parse_tag_as_version(&payload.tag_name)?;
136 let current_version = current_version();
137
138 let newer_available = latest_version > current_version;
139
140 Ok(UpdateStatus {
141 current: current_version,
142 newer_available,
143 latest: LatestRelease {
144 version: latest_version,
145 tag_name: payload.tag_name,
146 html_url: payload.html_url,
147 body: payload.body,
148 assets: payload.assets,
149 is_prerelease: payload.prerelease,
150 },
151 })
152}
153
154fn parse_tag_as_version(tag: &str) -> Result<Version, UpdateCheckError> {
157 let trimmed = tag.trim().trim_start_matches('v').trim_start_matches('V');
158 Version::parse(trimmed).map_err(|e| UpdateCheckError::InvalidTag(e.to_string()))
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn tag_parses_with_v_prefix() {
167 assert_eq!(
168 parse_tag_as_version("v1.2.3").unwrap(),
169 Version::parse("1.2.3").unwrap()
170 );
171 }
172
173 #[test]
174 fn tag_parses_without_v_prefix() {
175 assert_eq!(
176 parse_tag_as_version("1.2.3").unwrap(),
177 Version::parse("1.2.3").unwrap()
178 );
179 }
180
181 #[test]
182 fn tag_parses_prerelease() {
183 let v = parse_tag_as_version("v1.0.0-beta.1").unwrap();
184 assert_eq!(v, Version::parse("1.0.0-beta.1").unwrap());
185 assert!(!v.pre.is_empty(), "prerelease segment should be present");
186 }
187
188 #[test]
189 fn tag_parses_uppercase_v() {
190 assert_eq!(
191 parse_tag_as_version("V2.0.0").unwrap(),
192 Version::parse("2.0.0").unwrap()
193 );
194 }
195
196 #[test]
197 fn tag_rejects_nonsemver() {
198 assert!(matches!(
199 parse_tag_as_version("banana"),
200 Err(UpdateCheckError::InvalidTag(_))
201 ));
202 }
203
204 #[test]
205 fn tag_rejects_empty() {
206 assert!(matches!(
207 parse_tag_as_version(""),
208 Err(UpdateCheckError::InvalidTag(_))
209 ));
210 }
211
212 #[test]
213 fn current_version_matches_package_version() {
214 assert_eq!(current_version().to_string(), env!("CARGO_PKG_VERSION"));
215 }
216}