use chrono::{DateTime, Utc};
use par_term_config::{Config, UpdateCheckFrequency};
use parking_lot::Mutex;
use semver::Version;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
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().clone()
}
pub fn should_check(&self, config: &Config) -> bool {
if config.updates.update_check_frequency == UpdateCheckFrequency::Never {
return false;
}
let Some(check_interval_secs) = config.updates.update_check_frequency.as_seconds() else {
return false;
};
let Some(ref last_check_str) = config.updates.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 {
let last_time = self.last_check_time.lock();
if 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.updates.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);
}
*self.last_check_time.lock() = Some(Instant::now());
let result = self.perform_check(config);
*self.last_result.lock() = 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.updates.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> {
crate::http::validate_update_url(RELEASE_API_URL)?;
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 latest release info from GitHub: {}. \
Check your internet connection. \
You can view the latest release at: https://github.com/{}/releases/latest",
e, REPO
)
})?
.into_body();
let body_str = body
.with_config()
.limit(crate::http::MAX_API_RESPONSE_SIZE)
.read_to_string()
.map_err(|e| format!("Failed to read response body: {}", e))?;
let json: serde_json::Value =
serde_json::from_str(&body_str).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let version = json
.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "Could not find tag_name in release response".to_string())?;
let release_url = json
.get("html_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("https://github.com/{}/releases/latest", REPO));
let release_notes = json
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let published_at = json
.get("published_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(UpdateInfo {
version,
release_notes,
release_url,
published_at,
})
}
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_json_parsing_with_serde() {
let json_str = r#"{"tag_name":"v0.6.0","html_url":"https://example.com","body":"Release notes","published_at":"2024-01-01T00:00:00Z"}"#;
let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(
json.get("tag_name").and_then(|v| v.as_str()),
Some("v0.6.0")
);
assert_eq!(
json.get("html_url").and_then(|v| v.as_str()),
Some("https://example.com")
);
assert_eq!(json.get("missing").and_then(|v| v.as_str()), None);
}
#[test]
fn test_json_parsing_with_escapes() {
let json_str = r#"{"body":"Line 1\nLine 2\tTabbed"}"#;
let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
assert_eq!(
json.get("body").and_then(|v| v.as_str()),
Some("Line 1\nLine 2\tTabbed")
);
}
#[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 mut config = Config::default();
config.updates.update_check_frequency = UpdateCheckFrequency::Never;
assert!(!checker.should_check(&config));
}
#[test]
fn test_should_check_no_previous() {
let checker = UpdateChecker::new("0.0.0");
let mut config = Config::default();
config.updates.update_check_frequency = UpdateCheckFrequency::Weekly;
config.updates.last_update_check = None;
assert!(checker.should_check(&config));
}
#[test]
fn test_should_check_time_elapsed() {
let checker = UpdateChecker::new("0.0.0");
let mut config = Config::default();
config.updates.update_check_frequency = UpdateCheckFrequency::Daily;
let two_days_ago = Utc::now() - chrono::Duration::days(2);
config.updates.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.updates.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());
}
}