#![allow(dead_code)]
use crate::update::config::{Channel, UpdateConfig};
use crate::update::release::{GitHubRelease, ReleaseClient, ReleaseError};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateState {
#[serde(default)]
pub last_checked: Option<u64>,
#[serde(default)]
pub available_version: Option<String>,
#[serde(default)]
pub previous_version: Option<String>,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub notified: bool,
#[serde(default)]
pub changelog: Option<String>,
#[serde(default)]
pub release_url: Option<String>,
}
impl UpdateState {
pub fn load() -> Self {
Self::state_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::state_path()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "No state path"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)
}
fn state_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".jarvy").join("update-state.json"))
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn mark_checked(&mut self) {
self.last_checked = Some(Self::now());
}
pub fn record_available(&mut self, release: &GitHubRelease, channel: Channel) {
self.available_version = Some(release.version().to_string());
self.channel = Some(channel.as_str().to_string());
self.changelog = release.changelog().map(|s| s.to_string());
self.release_url = Some(release.html_url.clone());
self.notified = false;
}
pub fn clear_available(&mut self, previous: &str) {
self.previous_version = Some(previous.to_string());
self.available_version = None;
self.changelog = None;
self.release_url = None;
self.notified = false;
}
pub fn has_update(&self) -> bool {
if let Some(available) = &self.available_version {
let current = semver::Version::parse(CURRENT_VERSION).ok();
let avail = semver::Version::parse(available).ok();
match (current, avail) {
(Some(c), Some(a)) => a > c,
_ => false,
}
} else {
false
}
}
}
pub struct UpdateChecker {
config: UpdateConfig,
state: UpdateState,
client: ReleaseClient,
}
impl UpdateChecker {
pub fn new() -> Self {
Self {
config: UpdateConfig::load(),
state: UpdateState::load(),
client: ReleaseClient::new(),
}
}
pub fn with_config(config: UpdateConfig) -> Self {
Self {
config,
state: UpdateState::load(),
client: ReleaseClient::new(),
}
}
pub fn should_check(&self) -> bool {
if self.config.is_disabled() {
return false;
}
if UpdateConfig::force_check_requested() {
return true;
}
if let Some(last) = self.state.last_checked {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let elapsed = Duration::from_secs(now.saturating_sub(last));
elapsed >= self.config.check_interval
} else {
true
}
}
pub fn check(&mut self) -> Result<CheckResult, CheckError> {
if !self.should_check() {
if self.state.has_update() {
return Ok(CheckResult::UpdateAvailable {
current: CURRENT_VERSION.to_string(),
latest: self.state.available_version.clone().unwrap_or_default(),
changelog: self.state.changelog.clone(),
release_url: self.state.release_url.clone(),
});
}
return Ok(CheckResult::UpToDate);
}
let latest = self
.client
.fetch_latest(self.config.channel)
.map_err(CheckError::Release)?;
self.state.mark_checked();
match latest {
Some(release) => {
let current = semver::Version::parse(CURRENT_VERSION)
.map_err(|e| CheckError::Version(e.to_string()))?;
let latest_ver = release
.semver()
.ok_or_else(|| CheckError::Version("Invalid release version".to_string()))?;
let is_valid_update = if self.config.patch_only {
latest_ver.major == current.major && latest_ver.minor == current.minor
} else {
true
};
if latest_ver > current && is_valid_update {
self.state.record_available(&release, self.config.channel);
let _ = self.state.save();
Ok(CheckResult::UpdateAvailable {
current: CURRENT_VERSION.to_string(),
latest: release.version().to_string(),
changelog: release.changelog().map(|s| s.to_string()),
release_url: Some(release.html_url),
})
} else {
let _ = self.state.save();
Ok(CheckResult::UpToDate)
}
}
None => {
let _ = self.state.save();
Ok(CheckResult::UpToDate)
}
}
}
pub fn state(&self) -> &UpdateState {
&self.state
}
pub fn state_mut(&mut self) -> &mut UpdateState {
&mut self.state
}
pub fn config(&self) -> &UpdateConfig {
&self.config
}
pub fn should_auto_install(&self, latest: &str) -> bool {
let current = match semver::Version::parse(CURRENT_VERSION) {
Ok(v) => v,
Err(_) => return false,
};
let new = match semver::Version::parse(latest) {
Ok(v) => v,
Err(_) => return false,
};
self.config.auto_install.should_auto_install(¤t, &new)
}
pub fn mark_notified(&mut self) {
self.state.notified = true;
let _ = self.state.save();
}
pub fn should_notify(&self) -> bool {
self.config.show_notifications && self.state.has_update() && !self.state.notified
}
pub fn notification_message(&self) -> Option<String> {
if !self.should_notify() {
return None;
}
let available = self.state.available_version.as_ref()?;
Some(format!(
"A new version of Jarvy is available: {} -> {}\nRun 'jarvy update' to install or 'jarvy update check' for details.",
CURRENT_VERSION, available
))
}
}
impl Default for UpdateChecker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub enum CheckResult {
UpToDate,
UpdateAvailable {
current: String,
latest: String,
changelog: Option<String>,
release_url: Option<String>,
},
}
impl CheckResult {
pub fn has_update(&self) -> bool {
matches!(self, CheckResult::UpdateAvailable { .. })
}
}
#[derive(Debug, thiserror::Error)]
pub enum CheckError {
#[error("Release error: {0}")]
Release(#[from] ReleaseError),
#[error("Version error: {0}")]
Version(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_state_default() {
let state = UpdateState::default();
assert!(state.last_checked.is_none());
assert!(state.available_version.is_none());
assert!(!state.has_update());
}
#[test]
fn test_update_state_has_update() {
let mut state = UpdateState {
available_version: Some("999.0.0".to_string()),
..Default::default()
};
assert!(state.has_update());
state.available_version = Some("0.0.1".to_string());
assert!(!state.has_update());
}
#[test]
fn test_check_result() {
let up_to_date = CheckResult::UpToDate;
assert!(!up_to_date.has_update());
let available = CheckResult::UpdateAvailable {
current: "1.0.0".to_string(),
latest: "1.1.0".to_string(),
changelog: None,
release_url: None,
};
assert!(available.has_update());
}
#[test]
fn test_checker_should_check_disabled() {
let config = UpdateConfig {
enabled: false,
..Default::default()
};
let checker = UpdateChecker::with_config(config);
assert!(!checker.should_check());
}
#[test]
fn test_checker_should_check_pinned() {
let config = UpdateConfig {
pinned_version: Some("1.0.0".to_string()),
..Default::default()
};
let checker = UpdateChecker::with_config(config);
assert!(!checker.should_check());
}
}