use chrono::{DateTime, Utc};
use par_term_config::{Config, UpdateCheckFrequency};
use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const REPO: &str = "paulrobello/par-term";
const RELEASE_API_URL: &str = "https://api.github.com/repos/paulrobello/par-term/releases/latest";
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub version: String,
pub release_notes: Option<String>,
pub release_url: String,
pub published_at: Option<String>,
}
#[derive(Debug, Clone)]
pub enum UpdateCheckResult {
UpToDate,
UpdateAvailable(UpdateInfo),
Disabled,
Skipped,
Error(String),
}
pub struct UpdateChecker {
current_version: &'static str,
last_result: Arc<Mutex<Option<UpdateCheckResult>>>,
check_in_progress: Arc<AtomicBool>,
last_check_time: Arc<Mutex<Option<Instant>>>,
min_check_interval: Duration,
}
impl UpdateChecker {
pub fn new(current_version: &'static str) -> Self {
Self {
current_version,
last_result: Arc::new(Mutex::new(None)),
check_in_progress: Arc::new(AtomicBool::new(false)),
last_check_time: Arc::new(Mutex::new(None)),
min_check_interval: Duration::from_secs(3600),
}
}
pub fn last_result(&self) -> Option<UpdateCheckResult> {
self.last_result.lock().ok()?.clone()
}
pub fn should_check(&self, config: &Config) -> bool {
if config.update_check_frequency == UpdateCheckFrequency::Never {
return false;
}
let Some(check_interval_secs) = config.update_check_frequency.as_seconds() else {
return false;
};
let Some(ref last_check_str) = config.last_update_check else {
return true;
};
let Ok(last_check) = DateTime::parse_from_rfc3339(last_check_str) else {
return true;
};
let now = Utc::now();
let elapsed = now.signed_duration_since(last_check.with_timezone(&Utc));
let elapsed_secs = elapsed.num_seconds();
elapsed_secs >= check_interval_secs as i64
}
fn is_rate_limited(&self) -> bool {
if let Ok(last_time) = self.last_check_time.lock()
&& let Some(last) = *last_time
{
return last.elapsed() < self.min_check_interval;
}
false
}
pub fn check_now(&self, config: &Config, force: bool) -> (UpdateCheckResult, bool) {
if config.update_check_frequency == UpdateCheckFrequency::Never && !force {
return (UpdateCheckResult::Disabled, false);
}
if !force && !self.should_check(config) {
return (UpdateCheckResult::Skipped, false);
}
if !force && self.is_rate_limited() {
return (UpdateCheckResult::Skipped, false);
}
if self
.check_in_progress
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return (UpdateCheckResult::Skipped, false);
}
if let Ok(mut last_time) = self.last_check_time.lock() {
*last_time = Some(Instant::now());
}
let result = self.perform_check(config);
if let Ok(mut last_result) = self.last_result.lock() {
*last_result = Some(result.clone());
}
self.check_in_progress.store(false, Ordering::SeqCst);
let should_save = !matches!(result, UpdateCheckResult::Error(_));
(result, should_save)
}
fn perform_check(&self, config: &Config) -> UpdateCheckResult {
let current_version_str = self.current_version;
let current_version = match Version::parse(current_version_str) {
Ok(v) => v,
Err(e) => {
return UpdateCheckResult::Error(format!(
"Failed to parse current version '{}': {}",
current_version_str, e
));
}
};
let release_info = match fetch_latest_release() {
Ok(info) => info,
Err(e) => return UpdateCheckResult::Error(e),
};
let version_str = release_info
.version
.strip_prefix('v')
.unwrap_or(&release_info.version);
let latest_version = match Version::parse(version_str) {
Ok(v) => v,
Err(e) => {
return UpdateCheckResult::Error(format!(
"Failed to parse latest version '{}': {}",
release_info.version, e
));
}
};
if latest_version > current_version {
if let Some(ref skipped) = config.skipped_version
&& (skipped == version_str || skipped == &release_info.version)
{
return UpdateCheckResult::UpToDate;
}
UpdateCheckResult::UpdateAvailable(release_info)
} else {
UpdateCheckResult::UpToDate
}
}
}
pub fn fetch_latest_release() -> Result<UpdateInfo, String> {
let mut body = crate::http::agent()
.get(RELEASE_API_URL)
.header("User-Agent", "par-term")
.header("Accept", "application/vnd.github+json")
.call()
.map_err(|e| format!("Failed to fetch release info: {}", e))?
.into_body();
let body_str = body
.read_to_string()
.map_err(|e| format!("Failed to read response body: {}", e))?;
let version = extract_json_string(&body_str, "tag_name")
.ok_or_else(|| "Could not find tag_name in release response".to_string())?;
let release_url = extract_json_string(&body_str, "html_url")
.unwrap_or_else(|| format!("https://github.com/{}/releases/latest", REPO));
let release_notes = extract_json_string(&body_str, "body");
let published_at = extract_json_string(&body_str, "published_at");
Ok(UpdateInfo {
version,
release_notes,
release_url,
published_at,
})
}
fn extract_json_string(json: &str, key: &str) -> Option<String> {
let search_pattern = format!("\"{}\":\"", key);
let start_idx = json.find(&search_pattern)? + search_pattern.len();
let remaining = &json[start_idx..];
let mut chars = remaining.chars().peekable();
let mut value = String::new();
let mut escaped = false;
for ch in chars.by_ref() {
if escaped {
match ch {
'n' => value.push('\n'),
'r' => value.push('\r'),
't' => value.push('\t'),
'\\' => value.push('\\'),
'"' => value.push('"'),
_ => {
value.push('\\');
value.push(ch);
}
}
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
break;
} else {
value.push(ch);
}
}
if value.is_empty() { None } else { Some(value) }
}
pub fn current_timestamp() -> String {
Utc::now().to_rfc3339()
}
pub fn format_timestamp(timestamp: &str) -> String {
match DateTime::parse_from_rfc3339(timestamp) {
Ok(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
Err(_) => timestamp.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison() {
let v1 = Version::parse("0.5.0").unwrap();
let v2 = Version::parse("0.6.0").unwrap();
assert!(v2 > v1);
let v3 = Version::parse("1.0.0").unwrap();
assert!(v3 > v2);
}
#[test]
fn test_extract_json_string() {
let json = r#"{"tag_name":"v0.6.0","html_url":"https://example.com"}"#;
assert_eq!(
extract_json_string(json, "tag_name"),
Some("v0.6.0".to_string())
);
assert_eq!(
extract_json_string(json, "html_url"),
Some("https://example.com".to_string())
);
assert_eq!(extract_json_string(json, "missing"), None);
}
#[test]
fn test_extract_json_string_with_escapes() {
let json = r#"{"body":"Line 1\nLine 2\tTabbed"}"#;
assert_eq!(
extract_json_string(json, "body"),
Some("Line 1\nLine 2\tTabbed".to_string())
);
}
#[test]
fn test_update_check_frequency_seconds() {
assert_eq!(UpdateCheckFrequency::Never.as_seconds(), None);
assert_eq!(UpdateCheckFrequency::Hourly.as_seconds(), Some(3600));
assert_eq!(UpdateCheckFrequency::Daily.as_seconds(), Some(86400));
assert_eq!(UpdateCheckFrequency::Weekly.as_seconds(), Some(604800));
assert_eq!(UpdateCheckFrequency::Monthly.as_seconds(), Some(2592000));
}
#[test]
fn test_should_check_never() {
let checker = UpdateChecker::new("0.0.0");
let config = Config {
update_check_frequency: UpdateCheckFrequency::Never,
..Default::default()
};
assert!(!checker.should_check(&config));
}
#[test]
fn test_should_check_no_previous() {
let checker = UpdateChecker::new("0.0.0");
let config = Config {
update_check_frequency: UpdateCheckFrequency::Weekly,
last_update_check: None,
..Default::default()
};
assert!(checker.should_check(&config));
}
#[test]
fn test_should_check_time_elapsed() {
let checker = UpdateChecker::new("0.0.0");
let mut config = Config {
update_check_frequency: UpdateCheckFrequency::Daily,
..Default::default()
};
let two_days_ago = Utc::now() - chrono::Duration::days(2);
config.last_update_check = Some(two_days_ago.to_rfc3339());
assert!(checker.should_check(&config));
let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
config.last_update_check = Some(one_hour_ago.to_rfc3339());
assert!(!checker.should_check(&config));
}
#[test]
fn test_current_timestamp_format() {
let ts = current_timestamp();
assert!(DateTime::parse_from_rfc3339(&ts).is_ok());
}
}