use crate::upgrade::{normalize_tag, upgrade_hint, version_cmp, InstallMethod, ReleaseClient};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const CHECK_INTERVAL_SECS: u64 = 86_400;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateCheckOutcome {
Newer(String),
UpToDate,
Failed,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCheckState {
#[serde(default)]
pub last_check: u64,
#[serde(default)]
pub latest_seen: String,
}
pub fn state_path() -> Result<PathBuf> {
let dir = dirs::cache_dir().context("locate cache directory")?;
Ok(dir.join("duodiff").join("update_check.json"))
}
pub fn load_state(path: &Path) -> UpdateCheckState {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
pub fn save_state(path: &Path, state: &UpdateCheckState) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(state) {
let _ = std::fs::write(path, json);
}
}
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn should_check(last_check: u64, now: u64) -> bool {
now.saturating_sub(last_check) >= CHECK_INTERVAL_SECS
}
pub fn is_newer(latest: &str, current: &str) -> Option<String> {
let latest = normalize_tag(latest);
if latest.is_empty() {
return None;
}
(version_cmp(&latest, current) == Ordering::Greater).then_some(latest)
}
pub fn update_hint(latest: &str, method: &InstallMethod) -> String {
let cmd = upgrade_hint(method)
.map(str::to_string)
.or_else(|| match method {
InstallMethod::Standalone => Some("duodiff --upgrade".to_string()),
_ => None,
});
match cmd {
Some(cmd) => format!("⬆ v{latest} available — run {cmd}"),
None => format!("⬆ v{latest} available"),
}
}
pub fn check(client: &impl ReleaseClient, current: &str) -> UpdateCheckOutcome {
match client.fetch_latest_tag() {
Ok(tag) => match is_newer(&tag, current) {
Some(v) => UpdateCheckOutcome::Newer(v),
None => UpdateCheckOutcome::UpToDate,
},
Err(_) => UpdateCheckOutcome::Failed,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_check_respects_the_daily_interval() {
assert!(should_check(0, CHECK_INTERVAL_SECS));
assert!(should_check(0, CHECK_INTERVAL_SECS + 10));
assert!(!should_check(100, 100));
assert!(!should_check(100, 100 + CHECK_INTERVAL_SECS - 1));
}
#[test]
fn is_newer_only_for_strictly_greater() {
assert_eq!(is_newer("v0.2.0", "0.1.0"), Some("0.2.0".to_string()));
assert_eq!(is_newer("0.1.0", "0.1.0"), None);
assert_eq!(is_newer("v0.0.9", "0.1.0"), None);
assert_eq!(is_newer("", "0.1.0"), None);
}
#[test]
fn update_hint_is_install_method_aware() {
assert_eq!(
update_hint("0.2.0", &InstallMethod::Homebrew),
"⬆ v0.2.0 available — run brew upgrade duodiff"
);
assert_eq!(
update_hint("0.2.0", &InstallMethod::Standalone),
"⬆ v0.2.0 available — run duodiff --upgrade"
);
assert_eq!(
update_hint("0.2.0", &InstallMethod::Refuse { hint: "x".into() }),
"⬆ v0.2.0 available"
);
}
#[test]
fn check_classifies_outcomes_via_a_fake_client() {
struct Fake {
tag: Option<&'static str>,
}
impl ReleaseClient for Fake {
fn fetch_latest_tag(&self) -> Result<String> {
self.tag
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("offline"))
}
fn download(&self, _url: &str) -> Result<Vec<u8>> {
unreachable!("update check never downloads")
}
}
assert_eq!(
check(
&Fake {
tag: Some("v0.2.0")
},
"0.1.0"
),
UpdateCheckOutcome::Newer("0.2.0".to_string())
);
assert_eq!(
check(
&Fake {
tag: Some("v0.1.0")
},
"0.1.0"
),
UpdateCheckOutcome::UpToDate
);
assert_eq!(
check(&Fake { tag: None }, "0.1.0"),
UpdateCheckOutcome::Failed
);
}
#[test]
fn state_round_trips_through_disk() {
let dir = std::env::temp_dir().join(format!("duodiff-uctest-{}", std::process::id()));
let path = dir.join("update_check.json");
let state = UpdateCheckState {
last_check: 12345,
latest_seen: "0.2.0".to_string(),
};
save_state(&path, &state);
assert_eq!(load_state(&path), state);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(load_state(&path), UpdateCheckState::default());
}
}