use std::env;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_RELEASES_URL: &str = "https://api.github.com/repos/sinelaw/fresh/releases/latest";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallMethod {
Homebrew,
Cargo,
Npm,
PackageManager,
Aur,
Unknown,
}
impl InstallMethod {
pub fn update_command(&self) -> Option<&'static str> {
Some(match self {
Self::Homebrew => " brew upgrade fresh-editor",
Self::Cargo => "cargo install fresh-editor",
Self::Npm => "npm update -g @fresh-editor/fresh-editor",
Self::Aur => "yay -Syu fresh-editor # or use your AUR helper",
Self::PackageManager => "Update using your system package manager",
Self::Unknown => return None,
})
}
}
#[derive(Debug, Clone)]
pub struct ReleaseCheckResult {
pub latest_version: String,
pub update_available: bool,
pub install_method: InstallMethod,
}
pub struct UpdateCheckHandle {
receiver: Receiver<Result<ReleaseCheckResult, String>>,
#[allow(dead_code)]
thread: JoinHandle<()>,
}
impl UpdateCheckHandle {
pub fn try_get_result(self) -> Option<Result<ReleaseCheckResult, String>> {
match self.receiver.try_recv() {
Ok(result) => {
tracing::debug!("Update check completed");
Some(result)
}
Err(TryRecvError::Empty) => {
tracing::debug!("Update check still running, abandoning");
drop(self.thread);
None
}
Err(TryRecvError::Disconnected) => {
tracing::debug!("Update check thread disconnected");
None
}
}
}
}
pub const DEFAULT_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
pub struct PeriodicUpdateChecker {
receiver: Receiver<Result<ReleaseCheckResult, String>>,
stop_signal: Arc<AtomicBool>,
#[allow(dead_code)]
thread: JoinHandle<()>,
last_result: Option<ReleaseCheckResult>,
last_check_time: Option<Instant>,
}
impl PeriodicUpdateChecker {
pub fn poll_result(&mut self) -> Option<Result<ReleaseCheckResult, String>> {
match self.receiver.try_recv() {
Ok(result) => {
self.last_check_time = Some(Instant::now());
if let Ok(ref release_result) = result {
tracing::debug!(
"Periodic update check completed: update_available={}",
release_result.update_available
);
self.last_result = Some(release_result.clone());
}
Some(result)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
tracing::debug!("Periodic update checker thread disconnected");
None
}
}
}
pub fn get_cached_result(&self) -> Option<&ReleaseCheckResult> {
self.last_result.as_ref()
}
pub fn is_update_available(&self) -> bool {
self.last_result
.as_ref()
.map(|r| r.update_available)
.unwrap_or(false)
}
pub fn latest_version(&self) -> Option<&str> {
self.last_result.as_ref().and_then(|r| {
if r.update_available {
Some(r.latest_version.as_str())
} else {
None
}
})
}
}
impl Drop for PeriodicUpdateChecker {
fn drop(&mut self) {
self.stop_signal.store(true, Ordering::SeqCst);
}
}
pub fn start_periodic_update_check(releases_url: &str) -> PeriodicUpdateChecker {
start_periodic_update_check_with_interval(releases_url, DEFAULT_UPDATE_CHECK_INTERVAL)
}
pub fn start_periodic_update_check_with_interval(
releases_url: &str,
check_interval: Duration,
) -> PeriodicUpdateChecker {
tracing::debug!(
"Starting periodic update checker with interval {:?}",
check_interval
);
let url = releases_url.to_string();
let (tx, rx) = mpsc::channel();
let stop_signal = Arc::new(AtomicBool::new(false));
let stop_signal_clone = stop_signal.clone();
let sleep_increment = if check_interval < Duration::from_secs(10) {
Duration::from_millis(10)
} else {
Duration::from_secs(1)
};
let handle = thread::spawn(move || {
let result = check_for_update(&url);
if tx.send(result).is_err() {
return; }
loop {
let sleep_end = Instant::now() + check_interval;
while Instant::now() < sleep_end {
if stop_signal_clone.load(Ordering::SeqCst) {
tracing::debug!("Periodic update checker stopping");
return;
}
thread::sleep(sleep_increment);
}
if stop_signal_clone.load(Ordering::SeqCst) {
tracing::debug!("Periodic update checker stopping");
return;
}
tracing::debug!("Periodic update check starting");
let result = check_for_update(&url);
if tx.send(result).is_err() {
return; }
}
});
PeriodicUpdateChecker {
receiver: rx,
stop_signal,
thread: handle,
last_result: None,
last_check_time: None,
}
}
pub fn start_update_check(releases_url: &str) -> UpdateCheckHandle {
tracing::debug!("Starting background update check");
let url = releases_url.to_string();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let result = check_for_update(&url);
let _ = tx.send(result);
});
UpdateCheckHandle {
receiver: rx,
thread: handle,
}
}
pub fn fetch_latest_version(url: &str) -> Result<String, String> {
tracing::debug!("Fetching latest version from {}", url);
let response = ureq::get(url)
.set("User-Agent", "fresh-editor-update-checker")
.set("Accept", "application/vnd.github.v3+json")
.timeout(Duration::from_secs(5))
.call()
.map_err(|e| {
tracing::debug!("HTTP request failed: {}", e);
format!("HTTP request failed: {}", e)
})?;
let body = response
.into_string()
.map_err(|e| format!("Failed to read response body: {}", e))?;
let version = parse_version_from_json(&body)?;
tracing::debug!("Latest version: {}", version);
Ok(version)
}
fn parse_version_from_json(json: &str) -> Result<String, String> {
let tag_name_key = "\"tag_name\"";
let start = json
.find(tag_name_key)
.ok_or_else(|| "tag_name not found in response".to_string())?;
let after_key = &json[start + tag_name_key.len()..];
let value_start = after_key
.find('"')
.ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
let value_content = &after_key[value_start + 1..];
let value_end = value_content
.find('"')
.ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
let tag = &value_content[..value_end];
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
pub fn detect_install_method() -> InstallMethod {
match env::current_exe() {
Ok(path) => detect_install_method_from_path(&path),
Err(_) => InstallMethod::Unknown,
}
}
pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
let path_str = exe_path.to_string_lossy();
if path_str.contains("/opt/homebrew/")
|| path_str.contains("/usr/local/Cellar/")
|| path_str.contains("/home/linuxbrew/")
|| path_str.contains("/.linuxbrew/")
{
return InstallMethod::Homebrew;
}
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
return InstallMethod::Cargo;
}
if path_str.contains("/node_modules/")
|| path_str.contains("\\node_modules\\")
|| path_str.contains("/npm/")
|| path_str.contains("/lib/node_modules/")
{
return InstallMethod::Npm;
}
if path_str.starts_with("/usr/bin/") && is_arch_linux() {
return InstallMethod::Aur;
}
if path_str.starts_with("/usr/bin/")
|| path_str.starts_with("/usr/local/bin/")
|| path_str.starts_with("/bin/")
{
return InstallMethod::PackageManager;
}
InstallMethod::Unknown
}
fn is_arch_linux() -> bool {
std::fs::read_to_string("/etc/os-release")
.map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
.unwrap_or(false)
}
pub fn is_newer_version(current: &str, latest: &str) -> bool {
let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
let parts: Vec<&str> = v.split('.').collect();
if parts.len() >= 3 {
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].split('-').next()?.parse().ok()?,
))
} else if parts.len() == 2 {
Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
} else {
None
}
};
match (parse_version(current), parse_version(latest)) {
(Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
(l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
}
_ => false,
}
}
pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
let latest_version = fetch_latest_version(releases_url)?;
let install_method = detect_install_method();
let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
tracing::debug!(
current = CURRENT_VERSION,
latest = %latest_version,
update_available,
install_method = ?install_method,
"Release check complete"
);
Ok(ReleaseCheckResult {
latest_version,
update_available,
install_method,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_is_newer_version_major() {
assert!(is_newer_version("0.1.26", "1.0.0"));
assert!(is_newer_version("1.0.0", "2.0.0"));
}
#[test]
fn test_is_newer_version_minor() {
assert!(is_newer_version("0.1.26", "0.2.0"));
assert!(is_newer_version("0.1.26", "0.2.26"));
}
#[test]
fn test_is_newer_version_patch() {
assert!(is_newer_version("0.1.26", "0.1.27"));
assert!(is_newer_version("0.1.26", "0.1.100"));
}
#[test]
fn test_is_newer_version_same() {
assert!(!is_newer_version("0.1.26", "0.1.26"));
}
#[test]
fn test_is_newer_version_older() {
assert!(!is_newer_version("0.1.26", "0.1.25"));
assert!(!is_newer_version("0.2.0", "0.1.26"));
assert!(!is_newer_version("1.0.0", "0.1.26"));
}
#[test]
fn test_is_newer_version_with_v_prefix() {
assert!(is_newer_version("0.1.26", "0.1.27"));
}
#[test]
fn test_is_newer_version_with_prerelease() {
assert!(is_newer_version("0.1.26-alpha", "0.1.27"));
assert!(is_newer_version("0.1.26", "0.1.27-beta"));
}
#[test]
fn test_detect_install_method_homebrew_macos() {
let path = PathBuf::from("/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh");
assert_eq!(
detect_install_method_from_path(&path),
InstallMethod::Homebrew
);
}
#[test]
fn test_detect_install_method_homebrew_intel_mac() {
let path = PathBuf::from("/usr/local/Cellar/fresh/0.1.26/bin/fresh");
assert_eq!(
detect_install_method_from_path(&path),
InstallMethod::Homebrew
);
}
#[test]
fn test_detect_install_method_homebrew_linux() {
let path = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/fresh");
assert_eq!(
detect_install_method_from_path(&path),
InstallMethod::Homebrew
);
}
#[test]
fn test_detect_install_method_cargo() {
let path = PathBuf::from("/home/user/.cargo/bin/fresh");
assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
}
#[test]
fn test_detect_install_method_cargo_windows() {
let path = PathBuf::from("C:\\Users\\user\\.cargo\\bin\\fresh.exe");
assert_eq!(detect_install_method_from_path(&path), InstallMethod::Cargo);
}
#[test]
fn test_detect_install_method_npm() {
let path = PathBuf::from("/usr/local/lib/node_modules/fresh-editor/bin/fresh");
assert_eq!(detect_install_method_from_path(&path), InstallMethod::Npm);
}
#[test]
fn test_detect_install_method_package_manager() {
let path = PathBuf::from("/usr/local/bin/fresh");
assert_eq!(
detect_install_method_from_path(&path),
InstallMethod::PackageManager
);
}
#[test]
fn test_detect_install_method_unknown() {
let path = PathBuf::from("/home/user/downloads/fresh");
assert_eq!(
detect_install_method_from_path(&path),
InstallMethod::Unknown
);
}
#[test]
fn test_parse_version_from_json() {
let json = r#"{"tag_name": "v0.1.27", "name": "Release 0.1.27"}"#;
assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
}
#[test]
fn test_parse_version_from_json_no_v_prefix() {
let json = r#"{"tag_name": "0.1.27", "name": "Release 0.1.27"}"#;
assert_eq!(parse_version_from_json(json).unwrap(), "0.1.27");
}
#[test]
fn test_parse_version_from_json_full_response() {
let json = r#"{
"url": "https://api.github.com/repos/sinelaw/fresh/releases/12345",
"tag_name": "v0.2.0",
"target_commitish": "main",
"name": "v0.2.0",
"draft": false,
"prerelease": false
}"#;
assert_eq!(parse_version_from_json(json).unwrap(), "0.2.0");
}
#[test]
fn test_current_version_is_valid() {
let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
assert!(parts.len() >= 2, "Version should have at least major.minor");
assert!(
parts[0].parse::<u32>().is_ok(),
"Major version should be a number"
);
assert!(
parts[1].parse::<u32>().is_ok(),
"Minor version should be a number"
);
}
#[test]
fn test_version_parsing_with_mock_data() {
let json = r#"{"tag_name": "v99.0.0"}"#;
let version = parse_version_from_json(json).unwrap();
assert!(is_newer_version(CURRENT_VERSION, &version));
}
use std::sync::mpsc as std_mpsc;
fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
let port = server.server_addr().to_ip().unwrap().port();
let url = format!("http://127.0.0.1:{}/releases/latest", port);
let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
let version = version.to_string();
thread::spawn(move || {
loop {
if stop_rx.try_recv().is_ok() {
break;
}
match server.recv_timeout(Duration::from_millis(100)) {
Ok(Some(request)) => {
let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
let response = tiny_http::Response::from_string(response_body).with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json"[..],
)
.unwrap(),
);
let _ = request.respond(response);
}
Ok(None) => {
}
Err(_) => {
break;
}
}
}
});
(stop_tx, url)
}
#[test]
fn test_periodic_update_checker_with_local_server() {
let (stop_tx, url) = start_mock_release_server("99.0.0");
let mut checker =
start_periodic_update_check_with_interval(&url, Duration::from_millis(50));
let start = Instant::now();
while start.elapsed() < Duration::from_secs(2) {
if checker.poll_result().is_some() {
break;
}
thread::sleep(Duration::from_millis(10));
}
assert!(
checker.is_update_available(),
"Should detect update available"
);
assert_eq!(checker.latest_version(), Some("99.0.0"));
assert!(checker.get_cached_result().is_some());
drop(checker);
let _ = stop_tx.send(());
}
#[test]
fn test_periodic_update_checker_shutdown_clean() {
let (stop_tx, url) = start_mock_release_server("99.0.0");
let checker = start_periodic_update_check_with_interval(&url, Duration::from_millis(50));
thread::sleep(Duration::from_millis(100));
let start = Instant::now();
drop(checker);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"Shutdown took too long: {:?}",
elapsed
);
let _ = stop_tx.send(());
}
#[test]
fn test_periodic_update_checker_multiple_cycles_production() {
let (stop_tx, url) = start_mock_release_server("99.0.0");
let mut checker =
start_periodic_update_check_with_interval(&url, Duration::from_millis(30));
let mut result_count = 0;
let start = Instant::now();
let timeout = Duration::from_secs(2);
while start.elapsed() < timeout && result_count < 3 {
if checker.poll_result().is_some() {
result_count += 1;
}
thread::sleep(Duration::from_millis(10));
}
assert!(
result_count >= 2,
"Expected at least 2 results, got {}",
result_count
);
drop(checker);
let _ = stop_tx.send(());
}
#[test]
fn test_periodic_update_checker_no_update_when_current() {
let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
let mut checker =
start_periodic_update_check_with_interval(&url, Duration::from_secs(3600));
let start = Instant::now();
while start.elapsed() < Duration::from_secs(2) {
if checker.poll_result().is_some() {
break;
}
thread::sleep(Duration::from_millis(10));
}
assert!(!checker.is_update_available());
assert!(checker.latest_version().is_none()); assert!(checker.get_cached_result().is_some());
drop(checker);
let _ = stop_tx.send(());
}
#[test]
fn test_periodic_update_checker_api_before_result() {
let (stop_tx, url) = start_mock_release_server("99.0.0");
let checker = start_periodic_update_check_with_interval(&url, Duration::from_secs(3600));
assert!(!checker.is_update_available());
assert!(checker.latest_version().is_none());
assert!(checker.get_cached_result().is_none());
drop(checker);
let _ = stop_tx.send(());
}
}