par_term_update/
update_checker.rs1use chrono::{DateTime, Utc};
8use par_term_config::{Config, UpdateCheckFrequency};
9use parking_lot::Mutex;
10use semver::Version;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::{Duration, Instant};
14
15const REPO: &str = "paulrobello/par-term";
17
18const RELEASE_API_URL: &str = "https://api.github.com/repos/paulrobello/par-term/releases/latest";
20
21#[derive(Debug, Clone)]
23pub struct UpdateInfo {
24 pub version: String,
26 pub release_notes: Option<String>,
28 pub release_url: String,
30 pub published_at: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub enum UpdateCheckResult {
37 UpToDate,
39 UpdateAvailable(UpdateInfo),
41 Disabled,
43 Skipped,
45 Error(String),
47}
48
49pub struct UpdateChecker {
51 current_version: &'static str,
53 last_result: Arc<Mutex<Option<UpdateCheckResult>>>,
55 check_in_progress: Arc<AtomicBool>,
57 last_check_time: Arc<Mutex<Option<Instant>>>,
59 min_check_interval: Duration,
61}
62
63impl UpdateChecker {
64 pub fn new(current_version: &'static str) -> Self {
69 Self {
70 current_version,
71 last_result: Arc::new(Mutex::new(None)),
72 check_in_progress: Arc::new(AtomicBool::new(false)),
73 last_check_time: Arc::new(Mutex::new(None)),
74 min_check_interval: Duration::from_secs(3600),
76 }
77 }
78
79 pub fn last_result(&self) -> Option<UpdateCheckResult> {
81 self.last_result.lock().clone()
82 }
83
84 pub fn should_check(&self, config: &Config) -> bool {
86 if config.updates.update_check_frequency == UpdateCheckFrequency::Never {
88 return false;
89 }
90
91 let Some(check_interval_secs) = config.updates.update_check_frequency.as_seconds() else {
93 return false;
94 };
95
96 let Some(ref last_check_str) = config.updates.last_update_check else {
98 return true;
100 };
101
102 let Ok(last_check) = DateTime::parse_from_rfc3339(last_check_str) else {
104 return true;
106 };
107
108 let now = Utc::now();
110 let elapsed = now.signed_duration_since(last_check.with_timezone(&Utc));
111 let elapsed_secs = elapsed.num_seconds();
112
113 elapsed_secs >= check_interval_secs as i64
114 }
115
116 fn is_rate_limited(&self) -> bool {
118 let last_time = self.last_check_time.lock();
119 if let Some(last) = *last_time {
120 return last.elapsed() < self.min_check_interval;
121 }
122 false
123 }
124
125 pub fn check_now(&self, config: &Config, force: bool) -> (UpdateCheckResult, bool) {
130 if config.updates.update_check_frequency == UpdateCheckFrequency::Never && !force {
132 return (UpdateCheckResult::Disabled, false);
133 }
134
135 if !force && !self.should_check(config) {
137 return (UpdateCheckResult::Skipped, false);
138 }
139
140 if !force && self.is_rate_limited() {
142 return (UpdateCheckResult::Skipped, false);
143 }
144
145 if self
147 .check_in_progress
148 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
149 .is_err()
150 {
151 return (UpdateCheckResult::Skipped, false);
152 }
153
154 *self.last_check_time.lock() = Some(Instant::now());
156
157 let result = self.perform_check(config);
159
160 *self.last_result.lock() = Some(result.clone());
162
163 self.check_in_progress.store(false, Ordering::SeqCst);
165
166 let should_save = !matches!(result, UpdateCheckResult::Error(_));
168
169 (result, should_save)
170 }
171
172 fn perform_check(&self, config: &Config) -> UpdateCheckResult {
174 let current_version_str = self.current_version;
176 let current_version = match Version::parse(current_version_str) {
177 Ok(v) => v,
178 Err(e) => {
179 return UpdateCheckResult::Error(format!(
180 "Failed to parse current version '{}': {}",
181 current_version_str, e
182 ));
183 }
184 };
185
186 let release_info = match fetch_latest_release() {
188 Ok(info) => info,
189 Err(e) => return UpdateCheckResult::Error(e),
190 };
191
192 let version_str = release_info
194 .version
195 .strip_prefix('v')
196 .unwrap_or(&release_info.version);
197 let latest_version = match Version::parse(version_str) {
198 Ok(v) => v,
199 Err(e) => {
200 return UpdateCheckResult::Error(format!(
201 "Failed to parse latest version '{}': {}",
202 release_info.version, e
203 ));
204 }
205 };
206
207 if latest_version > current_version {
209 if let Some(ref skipped) = config.updates.skipped_version
211 && (skipped == version_str || skipped == &release_info.version)
212 {
213 return UpdateCheckResult::UpToDate;
214 }
215
216 UpdateCheckResult::UpdateAvailable(release_info)
217 } else {
218 UpdateCheckResult::UpToDate
219 }
220 }
221}
222
223pub fn fetch_latest_release() -> Result<UpdateInfo, String> {
225 crate::http::validate_update_url(RELEASE_API_URL)?;
227
228 let mut body = crate::http::agent()
229 .get(RELEASE_API_URL)
230 .header("User-Agent", "par-term")
231 .header("Accept", "application/vnd.github+json")
232 .call()
233 .map_err(|e| {
234 format!(
235 "Failed to fetch latest release info from GitHub: {}. \
236 Check your internet connection. \
237 You can view the latest release at: https://github.com/{}/releases/latest",
238 e, REPO
239 )
240 })?
241 .into_body();
242
243 let body_str = body
244 .with_config()
245 .limit(crate::http::MAX_API_RESPONSE_SIZE)
246 .read_to_string()
247 .map_err(|e| format!("Failed to read response body: {}", e))?;
248
249 let json: serde_json::Value =
251 serde_json::from_str(&body_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
252
253 let version = json
254 .get("tag_name")
255 .and_then(|v| v.as_str())
256 .map(|s| s.to_string())
257 .ok_or_else(|| "Could not find tag_name in release response".to_string())?;
258
259 let release_url = json
260 .get("html_url")
261 .and_then(|v| v.as_str())
262 .map(|s| s.to_string())
263 .unwrap_or_else(|| format!("https://github.com/{}/releases/latest", REPO));
264
265 let release_notes = json
266 .get("body")
267 .and_then(|v| v.as_str())
268 .map(|s| s.to_string());
269
270 let published_at = json
271 .get("published_at")
272 .and_then(|v| v.as_str())
273 .map(|s| s.to_string());
274
275 Ok(UpdateInfo {
276 version,
277 release_notes,
278 release_url,
279 published_at,
280 })
281}
282
283pub fn current_timestamp() -> String {
285 Utc::now().to_rfc3339()
286}
287
288pub fn format_timestamp(timestamp: &str) -> String {
290 match DateTime::parse_from_rfc3339(timestamp) {
291 Ok(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
292 Err(_) => timestamp.to_string(),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_version_comparison() {
302 let v1 = Version::parse("0.5.0").unwrap();
303 let v2 = Version::parse("0.6.0").unwrap();
304 assert!(v2 > v1);
305
306 let v3 = Version::parse("1.0.0").unwrap();
307 assert!(v3 > v2);
308 }
309
310 #[test]
311 fn test_json_parsing_with_serde() {
312 let json_str = r#"{"tag_name":"v0.6.0","html_url":"https://example.com","body":"Release notes","published_at":"2024-01-01T00:00:00Z"}"#;
314 let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
315
316 assert_eq!(
317 json.get("tag_name").and_then(|v| v.as_str()),
318 Some("v0.6.0")
319 );
320 assert_eq!(
321 json.get("html_url").and_then(|v| v.as_str()),
322 Some("https://example.com")
323 );
324 assert_eq!(json.get("missing").and_then(|v| v.as_str()), None);
325 }
326
327 #[test]
328 fn test_json_parsing_with_escapes() {
329 let json_str = r#"{"body":"Line 1\nLine 2\tTabbed"}"#;
331 let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
332
333 assert_eq!(
334 json.get("body").and_then(|v| v.as_str()),
335 Some("Line 1\nLine 2\tTabbed")
336 );
337 }
338
339 #[test]
340 fn test_update_check_frequency_seconds() {
341 assert_eq!(UpdateCheckFrequency::Never.as_seconds(), None);
342 assert_eq!(UpdateCheckFrequency::Hourly.as_seconds(), Some(3600));
343 assert_eq!(UpdateCheckFrequency::Daily.as_seconds(), Some(86400));
344 assert_eq!(UpdateCheckFrequency::Weekly.as_seconds(), Some(604800));
345 assert_eq!(UpdateCheckFrequency::Monthly.as_seconds(), Some(2592000));
346 }
347
348 #[test]
349 fn test_should_check_never() {
350 let checker = UpdateChecker::new("0.0.0");
351 let mut config = Config::default();
352 config.updates.update_check_frequency = UpdateCheckFrequency::Never;
353 assert!(!checker.should_check(&config));
354 }
355
356 #[test]
357 fn test_should_check_no_previous() {
358 let checker = UpdateChecker::new("0.0.0");
359 let mut config = Config::default();
360 config.updates.update_check_frequency = UpdateCheckFrequency::Weekly;
361 config.updates.last_update_check = None;
362 assert!(checker.should_check(&config));
363 }
364
365 #[test]
366 fn test_should_check_time_elapsed() {
367 let checker = UpdateChecker::new("0.0.0");
368 let mut config = Config::default();
369 config.updates.update_check_frequency = UpdateCheckFrequency::Daily;
370
371 let two_days_ago = Utc::now() - chrono::Duration::days(2);
373 config.updates.last_update_check = Some(two_days_ago.to_rfc3339());
374 assert!(checker.should_check(&config));
375
376 let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
378 config.updates.last_update_check = Some(one_hour_ago.to_rfc3339());
379 assert!(!checker.should_check(&config));
380 }
381
382 #[test]
383 fn test_current_timestamp_format() {
384 let ts = current_timestamp();
385 assert!(DateTime::parse_from_rfc3339(&ts).is_ok());
387 }
388}