//! Self-update mechanism. //! //! Checks the hosted herdr.dev update manifest for newer versions. //! Manual `herdr update` downloads and installs the binary. //! Background checks only surface availability and release notes. //! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies. //! JSON parsing uses serde_json (already in deps for persistence). use std::collections::BTreeMap; use std::env; use std::fs; use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; use serde::Deserialize; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION"; const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION"; const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0"; const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); fn fake_release_notes_body(version: &str) -> String { let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| DEFAULT_FAKE_UPDATE_NOTES_VERSION.to_string()); if let Some(notes) = crate::release_notes::load_preview_from_local_changelog(¬es_version) { return notes.body; } format!( "### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}." ) } // --------------------------------------------------------------------------- // Version // --------------------------------------------------------------------------- /// Parsed semver version for comparison. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Version { pub major: u32, pub minor: u32, pub patch: u32, } impl Version { pub fn parse(s: &str) -> Option { let s = s.strip_prefix('v').unwrap_or(s); let parts: Vec<&str> = s.split('.').collect(); if parts.len() != 3 { return None; } Some(Self { major: parts[0].parse().ok()?, minor: parts[1].parse().ok()?, patch: parts[2].parse().ok()?, }) } pub fn current() -> Self { Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION") } } impl std::fmt::Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } // --------------------------------------------------------------------------- // Update manifest // --------------------------------------------------------------------------- #[derive(Deserialize)] struct UpdateManifest { version: String, /// Thin-client protocol spoken by this release, when advertised by the manifest. protocol: Option, notes: String, assets: BTreeMap, } impl UpdateManifest { fn download_url_for(&self, os: &str, arch: &str) -> Option { self.assets.get(&format!("{os}-{arch}")).cloned() } fn notes_body(&self) -> String { self.notes.trim().to_string() } } // --------------------------------------------------------------------------- // Release info // --------------------------------------------------------------------------- /// Information about an available update. #[derive(Debug, Clone)] struct ReleaseInfo { version: Version, target_protocol: Option, download_url: String, notes_body: String, } /// Check the hosted update manifest for the latest release. Returns release info if newer. fn check_latest() -> Result, String> { let current = Version::current(); let output = Command::new("curl") .args([ "-sfL", "--retry", "3", "--connect-timeout", "10", "--max-time", "20", UPDATE_MANIFEST_URL, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Err("failed to fetch update manifest".into()); } let manifest: UpdateManifest = serde_json::from_slice(&output.stdout) .map_err(|e| format!("failed to parse update manifest JSON: {e}"))?; let latest = Version::parse(&manifest.version) .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?; if latest <= current { return Ok(None); // up to date } let notes_body = manifest.notes_body(); if notes_body.is_empty() { return Err("update manifest notes are empty".into()); } let (os, arch) = platform_target(); let download_url = manifest .download_url_for(os, arch) .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?; Ok(Some(ReleaseInfo { version: latest, target_protocol: manifest.protocol, download_url, notes_body, })) } // --------------------------------------------------------------------------- // Download + install // --------------------------------------------------------------------------- struct DownloadedUpdate { current_exe: PathBuf, tmp_path: Option, } impl Drop for DownloadedUpdate { fn drop(&mut self) { if let Some(tmp_path) = self.tmp_path.take() { let _ = fs::remove_file(tmp_path); } } } /// Download a release to a prepared executable temp file without touching the running server. fn download_update(release: &ReleaseInfo) -> Result { let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?; let parent = current_exe.parent().ok_or("can't find binary directory")?; // Check write permissions early let test_path = parent.join(".herdr-write-test"); if let Err(e) = fs::write(&test_path, b"") { let _ = fs::remove_file(&test_path); return Err(format!( "install directory not writable: {} ({}). Try running with appropriate permissions.", parent.display(), e )); } let _ = fs::remove_file(&test_path); // Unique temp file (avoids races with concurrent instances) let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id())); // Download the exact asset URL (pinned to the release we checked) let status = Command::new("curl") .args(["-sfL", "--max-time", "120", "-o"]) .arg(&tmp_path) .arg(&release.download_url) .status() .map_err(|e| format!("download failed: {e}"))?; if !status.success() { let _ = fs::remove_file(&tmp_path); return Err("download failed".into()); } // Make executable #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) { let _ = fs::remove_file(&tmp_path); return Err(format!("chmod failed: {e}")); } } Ok(DownloadedUpdate { current_exe, tmp_path: Some(tmp_path), }) } fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> { let tmp_path = update .tmp_path .take() .ok_or("downloaded update temp file is missing")?; // Atomic replace — rename over the current binary. // On Linux, the running process keeps its fd to the old inode. // Next launch picks up the new file. if let Err(e) = fs::rename(&tmp_path, &update.current_exe) { let _ = fs::remove_file(&tmp_path); return Err(format!("failed to replace binary: {e}")); } Ok(()) } // --------------------------------------------------------------------------- // Upgrade flow helpers // --------------------------------------------------------------------------- fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool { herdr_env == Some(crate::HERDR_ENV_VALUE) } fn running_inside_herdr() -> bool { running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref()) } fn api_server_is_running_at(socket_path: &Path) -> bool { if !socket_path.exists() { return false; } UnixStream::connect(socket_path).is_ok() } fn api_server_is_running() -> bool { api_server_is_running_at(&crate::api::socket_path()) } fn client_protocol_server_is_running_at(socket_path: &Path) -> bool { if !socket_path.exists() { return false; } UnixStream::connect(socket_path).is_ok() } fn client_protocol_server_is_running() -> bool { client_protocol_server_is_running_at(&crate::server::headless::client_socket_path()) } fn read_running_server_info() -> Result, String> { crate::api::read_runtime_status_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT) .map_err(|e| format!("failed to read running server status: {e}")) } fn protocol_label(protocol: Option) -> String { protocol .map(|value| value.to_string()) .unwrap_or_else(|| "unknown".to_string()) } fn version_label(version: Option<&str>) -> &str { version.unwrap_or("unknown") } fn update_requires_server_stop(server: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool { match (server.protocol, release.target_protocol) { (Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol, _ => true, } } fn parse_stop_server_before_update_response(input: &str) -> Option { let trimmed = input.trim().to_ascii_lowercase(); match trimmed.as_str() { "" | "n" | "no" => Some(false), "y" | "yes" => Some(true), _ => None, } } fn prompt_to_stop_server_before_update( server: &crate::api::RuntimeStatus, release: &ReleaseInfo, requires_stop: bool, ) -> Result { if !io::stdin().is_terminal() { if requires_stop { return Err(format!( "a herdr server is running and updating to v{} requires stopping it; run `herdr server stop`, then run `herdr update` again", release.version )); } eprintln!( "a herdr server is running. updating the binary will not affect that server until it restarts." ); return Ok(false); } eprintln!("a herdr server is currently running:"); eprintln!( " server: v{} protocol {}", version_label(server.version.as_deref()), protocol_label(server.protocol) ); eprintln!( " update: v{} protocol {}", release.version, protocol_label(release.target_protocol) ); eprintln!(); if requires_stop { eprintln!( "this update changes the herdr client/server protocol. the running server must be stopped before the new client can attach." ); eprintln!("stopping the server will end the current herdr session and its panes."); } else { eprintln!("updating the binary will not affect the running server until it restarts."); } loop { let prompt = if requires_stop { "stop the server and continue updating? [y/N] " } else { "stop the server before updating? [y/N] " }; eprint!("{prompt}"); io::stderr() .flush() .map_err(|e| format!("failed to flush prompt: {e}"))?; let mut input = String::new(); let read = io::stdin() .read_line(&mut input) .map_err(|e| format!("failed to read prompt response: {e}"))?; if read == 0 { return Ok(false); } if let Some(answer) = parse_stop_server_before_update_response(&input) { return Ok(answer); } eprintln!("please answer y or n"); } } #[derive(Debug, Clone, PartialEq, Eq)] struct RunningServerUpdatePlan { server: crate::api::RuntimeStatus, requires_stop: bool, } fn plan_running_server_update( release: &ReleaseInfo, ) -> Result, String> { let Some(server) = read_running_server_info()? else { if client_protocol_server_is_running() { return Err( "a herdr server is listening, but its status API is unavailable; try `herdr server stop`, or stop the old server process manually, then run `herdr update` again" .to_string(), ); } return Ok(None); }; let requires_stop = update_requires_server_stop(&server, release); Ok(Some(RunningServerUpdatePlan { server, requires_stop, })) } fn stop_running_server_for_update( plan: Option<&RunningServerUpdatePlan>, release: &ReleaseInfo, ) -> Result { let Some(plan) = plan else { return Ok(false); }; let stop_server = prompt_to_stop_server_before_update(&plan.server, release, plan.requires_stop)?; if !stop_server { if plan.requires_stop { return Err( "update cancelled; stop the running herdr server with `herdr server stop`, then run `herdr update` again" .to_string(), ); } return Ok(false); } stop_server_via_api()?; wait_for_server_shutdown(SERVER_SHUTDOWN_CONFIRM_TIMEOUT)?; eprintln!("stopped the running herdr server."); Ok(true) } fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{EmptyParams, Method, Request}; let request = Request { id: "update:server:stop".into(), method: Method::ServerStop(EmptyParams::default()), }; let mut stream = UnixStream::connect(socket_path) .map_err(|e| format!("failed to connect to running server: {e}"))?; stream .set_write_timeout(Some(timeout)) .map_err(|e| format!("failed to set server stop write timeout: {e}"))?; stream .set_read_timeout(Some(timeout)) .map_err(|e| format!("failed to set server stop read timeout: {e}"))?; stream .write_all( serde_json::to_string(&request) .map_err(|e| e.to_string())? .as_bytes(), ) .map_err(|e| format!("failed to send server stop request: {e}"))?; stream .write_all(b"\n") .map_err(|e| format!("failed to finish server stop request: {e}"))?; stream .flush() .map_err(|e| format!("failed to flush server stop request: {e}"))?; let mut reader = BufReader::new(stream); let mut line = String::new(); let read = reader .read_line(&mut line) .map_err(|e| format!("failed to read server stop response: {e}"))?; if read == 0 || line.trim().is_empty() { return Err("empty server stop response".into()); } let response: serde_json::Value = serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?; if let Some(error) = response.get("error") { return Err(format!("server stop failed: {error}")); } Ok(()) } fn stop_server_via_api() -> Result<(), String> { stop_server_via_api_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT) } fn server_shutdown_confirmed_at(socket_path: &Path) -> Result { if !socket_path.exists() { return Ok(true); } match UnixStream::connect(socket_path) { Ok(_) => Ok(false), Err(err) if matches!( err.kind(), io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound | io::ErrorKind::TimedOut ) => { Ok(true) } Err(err) => Err(format!( "failed to confirm whether the old server stopped on {}: {err}", socket_path.display() )), } } fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { let deadline = Instant::now() + timeout; loop { if server_shutdown_confirmed_at(socket_path)? { return Ok(()); } if Instant::now() >= deadline { return Err(format!( "shutdown was requested, but the old server is still responding on {} after {} seconds", socket_path.display(), timeout.as_secs() )); } std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL); } } fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> { wait_for_server_shutdown_at(&crate::api::socket_path(), timeout) } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Manual self-update command (`herdr update`). pub fn self_update() -> Result { if running_inside_herdr() { return Err("run `herdr update` outside herdr after detaching from the session".into()); } eprintln!("checking for updates..."); let current = Version::current(); let release = match check_latest()? { Some(r) => r, None => { eprintln!("already up to date (v{current})"); return Ok(current); } }; let running_server_plan = plan_running_server_update(&release)?; eprintln!("downloading v{}...", release.version); if let Err(e) = crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } let downloaded_update = download_update(&release)?; let updated_exe = downloaded_update.current_exe.clone(); let stopped_server = stop_running_server_for_update(running_server_plan.as_ref(), &release)?; install_downloaded_update(downloaded_update)?; eprintln!("updated to v{}", release.version); print_outdated_integration_notice_with_updated_binary(&updated_exe); if stopped_server { eprintln!("run herdr again to start the updated server."); } else if api_server_is_running() { eprintln!("the running herdr server will use the new version after it restarts."); } else { eprintln!("run herdr again."); } Ok(release.version) } fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) { let status = Command::new(updated_exe) .args(["integration", "status", "--outdated-only"]) .status(); if !status.is_ok_and(|status| status.success()) { crate::integration::print_outdated_update_notice(); } } /// Background update check: only surface availability and release notes. /// Runs in a background thread at startup. pub fn auto_update(events: tokio::sync::mpsc::Sender) { crate::logging::update_check_started(); if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) { let version = version.trim(); if !version.is_empty() { tracing::info!( env = FAKE_UPDATE_VERSION_ENV, version, "using fake update version for local testing" ); if let Err(e) = crate::release_notes::save_pending(version, &fake_release_notes_body(version)) { tracing::warn!("failed to save fake pending release notes: {e}"); } let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), }); } return; } let release = match check_latest() { Ok(Some(r)) => r, Ok(None) => return, Err(err) => { crate::logging::update_check_failed(&err); return; } }; crate::logging::update_available(&release.version.to_string()); tracing::info!( "new version v{} available at {}", release.version, release.download_url ); if let Err(e) = crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } tracing::info!( "auto-update check: v{} available, waiting for explicit install", release.version ); // Notify the TUI — blocking_send is safe from a std::thread let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: release.version.to_string(), }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn platform_target() -> (&'static str, &'static str) { let os = if cfg!(target_os = "linux") { "linux" } else if cfg!(target_os = "macos") { "macos" } else { "unknown" }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { "unknown" }; (os, arch) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::os::unix::net::UnixListener; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; use std::thread; fn unique_test_socket_path(name: &str) -> std::path::PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); std::path::PathBuf::from(format!( "/tmp/hu-{name}-{}-{nanos}.sock", std::process::id() )) } fn spawn_accept_loop(path: &Path) -> (Arc, thread::JoinHandle<()>) { let listener = UnixListener::bind(path).unwrap(); listener.set_nonblocking(true).unwrap(); let running = Arc::new(AtomicBool::new(true)); let running_thread = Arc::clone(&running); let handle = thread::spawn(move || { while running_thread.load(Ordering::Relaxed) { match listener.accept() { Ok((_stream, _)) => {} Err(err) if err.kind() == io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); } Err(_) => break, } } }); (running, handle) } #[test] fn parse_version_basic() { assert_eq!( Version::parse("1.2.3"), Some(Version { major: 1, minor: 2, patch: 3 }) ); } #[test] fn parse_version_with_v_prefix() { assert_eq!( Version::parse("v0.1.0"), Some(Version { major: 0, minor: 1, patch: 0 }) ); } #[test] fn parse_version_invalid() { assert_eq!(Version::parse("1.2"), None); assert_eq!(Version::parse("abc"), None); assert_eq!(Version::parse(""), None); } #[test] fn fake_release_notes_default_to_real_large_changelog_section() { std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); let body = fake_release_notes_body("9.4.9"); assert!(body.contains("### Major Changes")); assert!(body.contains("Added tabs within workspaces")); } #[test] fn fake_release_notes_fallback_include_version_and_context() { std::env::set_var(FAKE_UPDATE_NOTES_VERSION_ENV, "does-not-exist"); let body = fake_release_notes_body("9.4.9"); assert!(body.contains("v9.4.9")); assert!(body.contains("local UI validation")); assert!(body.contains(FAKE_UPDATE_VERSION_ENV)); std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); } #[test] fn running_inside_herdr_env_requires_marker() { assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE))); assert!(!running_inside_herdr_env(None)); assert!(!running_inside_herdr_env(Some("0"))); } #[test] fn parse_stop_server_before_update_response_defaults_no_for_blank() { assert_eq!(parse_stop_server_before_update_response(""), Some(false)); assert_eq!(parse_stop_server_before_update_response("\n"), Some(false)); assert_eq!(parse_stop_server_before_update_response("n"), Some(false)); assert_eq!(parse_stop_server_before_update_response("no"), Some(false)); assert_eq!(parse_stop_server_before_update_response("y"), Some(true)); assert_eq!(parse_stop_server_before_update_response("yes"), Some(true)); assert_eq!(parse_stop_server_before_update_response("later"), None); } #[test] fn update_requires_server_stop_when_target_protocol_differs_or_unknown() { let server = crate::api::RuntimeStatus { version: Some("0.5.5".to_string()), protocol: Some(2), }; let compatible_release = ReleaseInfo { version: Version::parse("0.5.6").unwrap(), target_protocol: Some(2), download_url: "https://example.com/herdr".to_string(), notes_body: "### Changed\n- One".to_string(), }; let incompatible_release = ReleaseInfo { target_protocol: Some(4), ..compatible_release.clone() }; let unknown_release = ReleaseInfo { target_protocol: None, ..compatible_release.clone() }; assert!(!update_requires_server_stop(&server, &compatible_release)); assert!(update_requires_server_stop(&server, &incompatible_release)); assert!(update_requires_server_stop(&server, &unknown_release)); } #[test] fn client_protocol_server_is_running_at_detects_live_socket() { let socket_path = unique_test_socket_path("client-live"); let listener = UnixListener::bind(&socket_path).unwrap(); assert!(client_protocol_server_is_running_at(&socket_path)); drop(listener); let _ = fs::remove_file(&socket_path); } #[test] fn client_protocol_server_is_running_at_ignores_missing_socket() { let socket_path = unique_test_socket_path("client-missing"); assert!(!client_protocol_server_is_running_at(&socket_path)); } #[test] fn stop_server_via_api_accepts_success_response() { let socket_path = unique_test_socket_path("stop-ok"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = String::new(); BufReader::new(stream.try_clone().unwrap()) .read_line(&mut request) .unwrap(); assert!( request.contains("server.stop") || request.contains("ServerStop") || request.contains("server_stop") || request.contains("ServerStop") ); stream .write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200)); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected stop request to succeed: {result:?}" ); } #[test] fn stop_server_via_api_times_out_when_server_never_replies() { let socket_path = unique_test_socket_path("stop-timeout"); let listener = UnixListener::bind(&socket_path).unwrap(); let handle = thread::spawn(move || { let (_stream, _) = listener.accept().unwrap(); thread::sleep(Duration::from_millis(200)); }); let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err(); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( err.contains("failed to read server stop response") || err.contains("empty server stop response"), "unexpected error: {err}" ); } #[test] fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() { let socket_path = unique_test_socket_path("shutdown-ok"); let (running, handle) = spawn_accept_loop(&socket_path); let running_for_stop = Arc::clone(&running); let stopper = thread::spawn(move || { thread::sleep(Duration::from_millis(60)); running_for_stop.store(false, Ordering::Relaxed); }); let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500)); running.store(false, Ordering::Relaxed); let _ = stopper.join(); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!(result.is_ok(), "expected shutdown confirmation: {result:?}"); } #[test] fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() { let socket_path = unique_test_socket_path("shutdown-timeout"); let (running, handle) = spawn_accept_loop(&socket_path); let err = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err(); running.store(false, Ordering::Relaxed); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!(err.contains("still responding"), "unexpected error: {err}"); } #[test] fn version_ordering() { let v010 = Version::parse("0.1.0").unwrap(); let v011 = Version::parse("0.1.1").unwrap(); let v020 = Version::parse("0.2.0").unwrap(); let v100 = Version::parse("1.0.0").unwrap(); assert!(v010 < v011); assert!(v011 < v020); assert!(v020 < v100); assert!(v010 == Version::parse("0.1.0").unwrap()); } #[test] fn version_display() { let v = Version { major: 0, minor: 1, patch: 0, }; assert_eq!(v.to_string(), "0.1.0"); } #[test] fn current_version_parses() { let v = Version::current(); assert!(v.major < 100); } #[test] fn platform_target_is_known() { let (os, arch) = platform_target(); assert!(os == "linux" || os == "macos", "os: {os}"); assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}"); } #[test] fn update_manifest_deserializes() { let json = "{\n\ \"version\": \"0.2.0\",\n\ \"protocol\": 4,\n\ \"notes\": \"### Changed\\n- One\",\n\ \"assets\": {\n\ \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\ \"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\ }\n\ }"; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert_eq!(manifest.version, "0.2.0"); assert_eq!(manifest.protocol, Some(4)); assert_eq!(manifest.assets.len(), 2); assert_eq!(manifest.notes_body(), "### Changed\n- One"); assert_eq!( manifest.download_url_for("linux", "x86_64").as_deref(), Some("https://example.com/herdr-linux-x86_64") ); } #[test] fn update_manifest_requires_notes_field() { let json = r#"{ "version": "0.2.0", "assets": { "linux-x86_64": "https://example.com/herdr-linux-x86_64" } }"#; assert!(serde_json::from_str::(json).is_err()); } #[test] fn checked_in_website_manifest_matches_update_schema() { let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) .expect("website/latest.json should match updater schema"); assert!(!manifest.notes_body().is_empty()); assert_eq!( manifest.protocol, Some(crate::server::protocol::PROTOCOL_VERSION) ); assert_eq!(manifest.assets.len(), 4); for target in [ "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", ] { let url = manifest .assets .get(target) .unwrap_or_else(|| panic!("missing asset URL for {target}")); assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" ); assert!( url.ends_with(&format!("herdr-{target}")), "unexpected asset name for {target}: {url}" ); } } }