1use chrono::{DateTime, Utc};
8use par_term_config::{Config, UpdateCheckFrequency};
9use semver::Version;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14const REPO: &str = "paulrobello/par-term";
16
17const RELEASE_API_URL: &str = "https://api.github.com/repos/paulrobello/par-term/releases/latest";
19
20#[derive(Debug, Clone)]
22pub struct UpdateInfo {
23 pub version: String,
25 pub release_notes: Option<String>,
27 pub release_url: String,
29 pub published_at: Option<String>,
31}
32
33#[derive(Debug, Clone)]
35pub enum UpdateCheckResult {
36 UpToDate,
38 UpdateAvailable(UpdateInfo),
40 Disabled,
42 Skipped,
44 Error(String),
46}
47
48pub struct UpdateChecker {
50 current_version: &'static str,
52 last_result: Arc<Mutex<Option<UpdateCheckResult>>>,
54 check_in_progress: Arc<AtomicBool>,
56 last_check_time: Arc<Mutex<Option<Instant>>>,
58 min_check_interval: Duration,
60}
61
62impl UpdateChecker {
63 pub fn new(current_version: &'static str) -> Self {
68 Self {
69 current_version,
70 last_result: Arc::new(Mutex::new(None)),
71 check_in_progress: Arc::new(AtomicBool::new(false)),
72 last_check_time: Arc::new(Mutex::new(None)),
73 min_check_interval: Duration::from_secs(3600),
75 }
76 }
77
78 pub fn last_result(&self) -> Option<UpdateCheckResult> {
80 self.last_result.lock().ok()?.clone()
81 }
82
83 pub fn should_check(&self, config: &Config) -> bool {
85 if config.update_check_frequency == UpdateCheckFrequency::Never {
87 return false;
88 }
89
90 let Some(check_interval_secs) = config.update_check_frequency.as_seconds() else {
92 return false;
93 };
94
95 let Some(ref last_check_str) = config.last_update_check else {
97 return true;
99 };
100
101 let Ok(last_check) = DateTime::parse_from_rfc3339(last_check_str) else {
103 return true;
105 };
106
107 let now = Utc::now();
109 let elapsed = now.signed_duration_since(last_check.with_timezone(&Utc));
110 let elapsed_secs = elapsed.num_seconds();
111
112 elapsed_secs >= check_interval_secs as i64
113 }
114
115 fn is_rate_limited(&self) -> bool {
117 if let Ok(last_time) = self.last_check_time.lock()
118 && let Some(last) = *last_time
119 {
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.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 if let Ok(mut last_time) = self.last_check_time.lock() {
156 *last_time = Some(Instant::now());
157 }
158
159 let result = self.perform_check(config);
161
162 if let Ok(mut last_result) = self.last_result.lock() {
164 *last_result = Some(result.clone());
165 }
166
167 self.check_in_progress.store(false, Ordering::SeqCst);
169
170 let should_save = !matches!(result, UpdateCheckResult::Error(_));
172
173 (result, should_save)
174 }
175
176 fn perform_check(&self, config: &Config) -> UpdateCheckResult {
178 let current_version_str = self.current_version;
180 let current_version = match Version::parse(current_version_str) {
181 Ok(v) => v,
182 Err(e) => {
183 return UpdateCheckResult::Error(format!(
184 "Failed to parse current version '{}': {}",
185 current_version_str, e
186 ));
187 }
188 };
189
190 let release_info = match fetch_latest_release() {
192 Ok(info) => info,
193 Err(e) => return UpdateCheckResult::Error(e),
194 };
195
196 let version_str = release_info
198 .version
199 .strip_prefix('v')
200 .unwrap_or(&release_info.version);
201 let latest_version = match Version::parse(version_str) {
202 Ok(v) => v,
203 Err(e) => {
204 return UpdateCheckResult::Error(format!(
205 "Failed to parse latest version '{}': {}",
206 release_info.version, e
207 ));
208 }
209 };
210
211 if latest_version > current_version {
213 if let Some(ref skipped) = config.skipped_version
215 && (skipped == version_str || skipped == &release_info.version)
216 {
217 return UpdateCheckResult::UpToDate;
218 }
219
220 UpdateCheckResult::UpdateAvailable(release_info)
221 } else {
222 UpdateCheckResult::UpToDate
223 }
224 }
225}
226
227pub fn fetch_latest_release() -> Result<UpdateInfo, String> {
229 let mut body = crate::http::agent()
230 .get(RELEASE_API_URL)
231 .header("User-Agent", "par-term")
232 .header("Accept", "application/vnd.github+json")
233 .call()
234 .map_err(|e| format!("Failed to fetch release info: {}", e))?
235 .into_body();
236
237 let body_str = body
238 .read_to_string()
239 .map_err(|e| format!("Failed to read response body: {}", e))?;
240
241 let version = extract_json_string(&body_str, "tag_name")
243 .ok_or_else(|| "Could not find tag_name in release response".to_string())?;
244
245 let release_url = extract_json_string(&body_str, "html_url")
246 .unwrap_or_else(|| format!("https://github.com/{}/releases/latest", REPO));
247
248 let release_notes = extract_json_string(&body_str, "body");
249 let published_at = extract_json_string(&body_str, "published_at");
250
251 Ok(UpdateInfo {
252 version,
253 release_notes,
254 release_url,
255 published_at,
256 })
257}
258
259fn extract_json_string(json: &str, key: &str) -> Option<String> {
261 let search_pattern = format!("\"{}\":\"", key);
262 let start_idx = json.find(&search_pattern)? + search_pattern.len();
263 let remaining = &json[start_idx..];
264
265 let mut chars = remaining.chars().peekable();
267 let mut value = String::new();
268 let mut escaped = false;
269
270 for ch in chars.by_ref() {
271 if escaped {
272 match ch {
273 'n' => value.push('\n'),
274 'r' => value.push('\r'),
275 't' => value.push('\t'),
276 '\\' => value.push('\\'),
277 '"' => value.push('"'),
278 _ => {
279 value.push('\\');
280 value.push(ch);
281 }
282 }
283 escaped = false;
284 } else if ch == '\\' {
285 escaped = true;
286 } else if ch == '"' {
287 break;
288 } else {
289 value.push(ch);
290 }
291 }
292
293 if value.is_empty() { None } else { Some(value) }
294}
295
296pub fn current_timestamp() -> String {
298 Utc::now().to_rfc3339()
299}
300
301pub fn format_timestamp(timestamp: &str) -> String {
303 match DateTime::parse_from_rfc3339(timestamp) {
304 Ok(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
305 Err(_) => timestamp.to_string(),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_version_comparison() {
315 let v1 = Version::parse("0.5.0").unwrap();
316 let v2 = Version::parse("0.6.0").unwrap();
317 assert!(v2 > v1);
318
319 let v3 = Version::parse("1.0.0").unwrap();
320 assert!(v3 > v2);
321 }
322
323 #[test]
324 fn test_extract_json_string() {
325 let json = r#"{"tag_name":"v0.6.0","html_url":"https://example.com"}"#;
326 assert_eq!(
327 extract_json_string(json, "tag_name"),
328 Some("v0.6.0".to_string())
329 );
330 assert_eq!(
331 extract_json_string(json, "html_url"),
332 Some("https://example.com".to_string())
333 );
334 assert_eq!(extract_json_string(json, "missing"), None);
335 }
336
337 #[test]
338 fn test_extract_json_string_with_escapes() {
339 let json = r#"{"body":"Line 1\nLine 2\tTabbed"}"#;
340 assert_eq!(
341 extract_json_string(json, "body"),
342 Some("Line 1\nLine 2\tTabbed".to_string())
343 );
344 }
345
346 #[test]
347 fn test_update_check_frequency_seconds() {
348 assert_eq!(UpdateCheckFrequency::Never.as_seconds(), None);
349 assert_eq!(UpdateCheckFrequency::Hourly.as_seconds(), Some(3600));
350 assert_eq!(UpdateCheckFrequency::Daily.as_seconds(), Some(86400));
351 assert_eq!(UpdateCheckFrequency::Weekly.as_seconds(), Some(604800));
352 assert_eq!(UpdateCheckFrequency::Monthly.as_seconds(), Some(2592000));
353 }
354
355 #[test]
356 fn test_should_check_never() {
357 let checker = UpdateChecker::new("0.0.0");
358 let config = Config {
359 update_check_frequency: UpdateCheckFrequency::Never,
360 ..Default::default()
361 };
362 assert!(!checker.should_check(&config));
363 }
364
365 #[test]
366 fn test_should_check_no_previous() {
367 let checker = UpdateChecker::new("0.0.0");
368 let config = Config {
369 update_check_frequency: UpdateCheckFrequency::Weekly,
370 last_update_check: None,
371 ..Default::default()
372 };
373 assert!(checker.should_check(&config));
374 }
375
376 #[test]
377 fn test_should_check_time_elapsed() {
378 let checker = UpdateChecker::new("0.0.0");
379 let mut config = Config {
380 update_check_frequency: UpdateCheckFrequency::Daily,
381 ..Default::default()
382 };
383
384 let two_days_ago = Utc::now() - chrono::Duration::days(2);
386 config.last_update_check = Some(two_days_ago.to_rfc3339());
387 assert!(checker.should_check(&config));
388
389 let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
391 config.last_update_check = Some(one_hour_ago.to_rfc3339());
392 assert!(!checker.should_check(&config));
393 }
394
395 #[test]
396 fn test_current_timestamp_format() {
397 let ts = current_timestamp();
398 assert!(DateTime::parse_from_rfc3339(&ts).is_ok());
400 }
401}