//! 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, Stdio}; use std::time::{Duration, Instant}; use serde::{Deserialize, Deserializer, Serialize}; const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json"; const HERDR_UPDATE_COMMAND: &str = "herdr update"; const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr"; const NIX_UPDATE_COMMAND: &str = "update through Nix"; 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_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30); const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); const STAR_PROMPT_REPO: &str = "ogulcancelik/herdr"; const STAR_PROMPT_STATE_FILE: &str = "github-star-prompt.json"; const STAR_PROMPT_MAX_PROMPTS: u32 = 5; const STAR_PROMPT_INTERVAL_UPDATES: [u32; 4] = [2, 3, 5, 7]; const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); 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, announcement: Option, #[serde(default, deserialize_with = "deserialize_manifest_releases")] releases: BTreeMap, } fn deserialize_manifest_releases<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { let value = Option::::deserialize(deserializer)?; Ok(match value { Some(serde_json::Value::Object(object)) => object.into_iter().collect(), _ => BTreeMap::new(), }) } #[derive(Deserialize)] struct ManifestReleaseMetadata { notes: String, announcement: Option, } #[derive(Deserialize)] struct HomebrewFormula { versions: HomebrewFormulaVersions, } #[derive(Deserialize)] struct HomebrewFormulaVersions { stable: String, } impl UpdateManifest { fn download_url_for(&self, os: &str, arch: &str) -> Option { self.assets.get(&format!("{os}-{arch}")).cloned() } fn metadata_for_version(&self, version: &Version) -> Option { let version = version.to_string(); if self.version.trim_start_matches('v') == version { return Some(ManifestReleaseMetadata { notes: self.notes.clone(), announcement: self.announcement.clone(), }); } self.releases.get(&version).and_then(|release| { let metadata = serde_json::from_value::(release.clone()).ok()?; (!metadata.notes_body().is_empty()).then_some(metadata) }) } } impl ManifestReleaseMetadata { 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, } fn fetch_update_manifest() -> Result { 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()); } serde_json::from_slice(&output.stdout) .map_err(|e| format!("failed to parse update manifest JSON: {e}")) } fn handle_manifest_announcement(version: &str, value: Option<&serde_json::Value>) { let announcement = match value { Some(value) => match serde_json::from_value::< crate::product_announcements::ManifestAnnouncement, >(value.clone()) { Ok(announcement) => Some(announcement), Err(err) => { tracing::warn!("skipping invalid product announcement in update manifest: {err}"); None } }, None => None, }; if let Err(err) = crate::product_announcements::save_manifest_announcement(version, announcement.as_ref()) { tracing::warn!("failed to save product announcement: {err}"); } } fn release_info_from_manifest(manifest: &UpdateManifest) -> Result, String> { let current = Version::current(); 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 metadata = manifest .metadata_for_version(&latest) .ok_or_else(|| format!("missing release metadata for v{latest}"))?; let notes_body = metadata.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, })) } /// Check the hosted update manifest for the latest release. Returns release info if newer. fn check_latest() -> Result, String> { let manifest = fetch_update_manifest()?; let release = release_info_from_manifest(&manifest)?; if let Some(release) = &release { if let Some(metadata) = manifest.metadata_for_version(&release.version) { handle_manifest_announcement( &release.version.to_string(), metadata.announcement.as_ref(), ); } } Ok(release) } fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result { let formula: HomebrewFormula = serde_json::from_slice(input) .map_err(|e| format!("failed to parse Homebrew formula JSON: {e}"))?; Version::parse(&formula.versions.stable).ok_or_else(|| { format!( "invalid stable version in Homebrew formula JSON: {}", formula.versions.stable ) }) } fn check_homebrew_latest() -> Result, String> { let current = Version::current(); let output = Command::new("curl") .args([ "-sfL", "--retry", "2", "--connect-timeout", "5", "--max-time", "10", HOMEBREW_FORMULA_API_URL, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { return Ok(None); } let latest = parse_homebrew_formula_stable_version(&output.stdout)?; if latest <= current { return Ok(None); } Ok(Some(latest)) } // --------------------------------------------------------------------------- // 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 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::socket_paths::client_socket_path()) } 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_live_handoff(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 server_supports_live_handoff(server: &crate::api::RuntimeStatus) -> bool { server .capabilities .as_ref() .is_some_and(|capabilities| capabilities.live_handoff) } fn parse_live_handoff_before_update_response(input: &str) -> Option { let trimmed = input.trim().to_ascii_lowercase(); match trimmed.as_str() { "" | "y" | "yes" => Some(true), "n" | "no" => Some(false), _ => None, } } #[derive(Debug, Clone, PartialEq, Eq)] struct RunningServerUpdatePlan { target: RunningUpdateTarget, server: crate::api::RuntimeStatus, requires_live_handoff: bool, } impl RunningServerUpdatePlan { fn label(&self) -> &str { &self.target.label } fn socket_path(&self) -> &Path { &self.target.socket_path } fn stop_command(&self) -> String { self.target.stop_command.clone() } fn attach_command(&self) -> Option { self.target.attach_command.clone() } fn target_noun(&self) -> &'static str { if self.target.attach_command.is_some() { "session" } else { "server" } } } #[derive(Debug, Clone, PartialEq, Eq)] struct RunningServerUpdateDecision { plan: RunningServerUpdatePlan, action: RunningServerUpdateAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RunningServerUpdateAction { None, LiveHandoff, StopOldServer, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RunningServerUpdateOutcome { RestartDeferred, Stopped, LiveHandoffComplete, FailedHandoffOldServerKept, FailedHandoffOldServerStopped, FailedHandoffNoServer, FailedHandoffUnknown, } #[derive(Debug, Clone, PartialEq, Eq)] struct RunningSessionUpdateOutcome { session_label: String, stop_command: String, attach_command: Option, outcome: RunningServerUpdateOutcome, } #[derive(Debug, Clone, PartialEq, Eq)] enum FailedHandoffServerState { UpdatedServerRunning, OldServerRunning(crate::api::RuntimeStatus), NoServerResponding, Unknown(String), } fn plan_running_server_updates( release: &ReleaseInfo, ) -> Result, String> { let targets = running_update_targets()?; let mut plans = Vec::new(); for target in targets { let server = match crate::api::read_runtime_status_at( &target.socket_path, SERVER_STOP_RESPONSE_TIMEOUT, ) .map_err(|err| { format!( "failed to read status for herdr target {} at {}: {err}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command ) })? { Some(server) => server, None if target.must_be_running => { return Err(format!( "herdr target {} looked running, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command )); } None if client_protocol_server_is_running_at(&target.client_socket_path) => { return Err(format!( "herdr target {} has a client socket, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again", target.label, target.socket_path.display(), target.stop_command )); } None => continue, }; plans.push(RunningServerUpdatePlan { requires_live_handoff: update_requires_live_handoff(&server, release), server, target, }); } if plans.is_empty() && target_client_protocol_server_is_running()? { return Err(format!( "a herdr server is listening, but its status API is unavailable; try `{}`, or stop the old server process manually, then run `herdr update` again", crate::session::local_stop_command() )); } Ok(plans) } #[derive(Debug, Clone, PartialEq, Eq)] struct RunningUpdateTarget { name: Option, label: String, stop_command: String, attach_command: Option, socket_path: PathBuf, client_socket_path: PathBuf, must_be_running: bool, } fn running_update_targets() -> Result, String> { if crate::session::explicit_session_requested() { return Ok(vec![RunningUpdateTarget { name: crate::session::active_name(), label: crate::session::active_name() .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()), stop_command: crate::session::local_stop_command(), attach_command: Some(crate::session::local_attach_command()), socket_path: crate::api::socket_path(), client_socket_path: crate::server::socket_paths::client_socket_path(), must_be_running: false, }]); } if let Some(socket_path) = std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR) { let socket_path = PathBuf::from(socket_path); return Ok(vec![RunningUpdateTarget { name: None, label: socket_path.display().to_string(), stop_command: format!( "{}={} herdr server stop", crate::api::SOCKET_PATH_ENV_VAR, socket_path.display() ), attach_command: None, client_socket_path: crate::server::socket_paths::client_socket_path_from_overrides( Some(&socket_path.to_string_lossy()), None, ), socket_path, must_be_running: false, }]); } let sessions = crate::session::list_sessions() .map_err(|err| format!("failed to list herdr sessions: {err}"))?; Ok(sessions .into_iter() .map(|session| RunningUpdateTarget { name: if session.default { None } else { Some(session.name.clone()) }, stop_command: crate::session::stop_command_for(if session.default { None } else { Some(&session.name) }), attach_command: Some(if session.default { "herdr".to_string() } else { format!("herdr session attach {}", session.name) }), label: session.name.clone(), client_socket_path: crate::session::client_socket_path_for(if session.default { None } else { Some(&session.name) }), socket_path: PathBuf::from(session.socket_path), must_be_running: session.running, }) .collect()) } fn target_client_protocol_server_is_running() -> Result { if crate::session::explicit_session_requested() || std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() { return Ok(client_protocol_server_is_running()); } let sessions = crate::session::list_sessions() .map_err(|err| format!("failed to list herdr sessions: {err}"))?; Ok(sessions.into_iter().any(|session| { let client_socket = crate::session::client_socket_path_for(if session.default { None } else { Some(&session.name) }); client_protocol_server_is_running_at(&client_socket) })) } fn prompt_to_stop_old_servers_before_update( plans: &[RunningServerUpdatePlan], release: &ReleaseInfo, ) -> Result { if !io::stdin().is_terminal() { return Err( "one or more herdr targets are running and cannot perform live handoff for this update; run `herdr update` from an interactive terminal, or stop those targets and run `herdr update` again" .to_string(), ); } eprintln!( "these running herdr targets are too old to preserve panes during this update to v{}:", release.version ); for plan in plans { eprintln!( " {}: v{} protocol {}", plan.label(), version_label(plan.server.version.as_deref()), protocol_label(plan.server.protocol) ); } eprintln!( "herdr can leave them running, or stop them after installing the update. stopping them will exit their pane processes." ); loop { eprint!("stop these old targets after updating? [y/N] "); 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); } match input.trim().to_ascii_lowercase().as_str() { "y" | "yes" => return Ok(true), "" | "n" | "no" => return Ok(false), _ => eprintln!("please answer y or n"), } } } fn confirm_running_server_update_action( plans: Vec, release: &ReleaseInfo, ) -> Result, String> { if plans.is_empty() { return Ok(Vec::new()); } print_running_session_update_summary(&plans, release); let handoff_supported: Vec<&RunningServerUpdatePlan> = plans .iter() .filter(|plan| server_supports_live_handoff(&plan.server)) .collect(); let handoff_unsupported_requiring_update: Vec<&RunningServerUpdatePlan> = plans .iter() .filter(|plan| !server_supports_live_handoff(&plan.server) && plan.requires_live_handoff) .collect(); let live_handoff = if handoff_supported.is_empty() { false } else { prompt_to_live_handoff_sessions_before_update( &handoff_supported, release, plans.iter().any(|plan| plan.requires_live_handoff), )? }; let stop_unsupported = if handoff_unsupported_requiring_update.is_empty() { false } else { let owned: Vec = handoff_unsupported_requiring_update .iter() .map(|plan| (*plan).clone()) .collect(); prompt_to_stop_old_servers_before_update(&owned, release)? }; let mut decisions = Vec::new(); for plan in plans { let action = if server_supports_live_handoff(&plan.server) && live_handoff { RunningServerUpdateAction::LiveHandoff } else if !server_supports_live_handoff(&plan.server) && plan.requires_live_handoff && stop_unsupported { RunningServerUpdateAction::StopOldServer } else { RunningServerUpdateAction::None }; decisions.push(RunningServerUpdateDecision { plan, action }); } Ok(decisions) } fn print_running_session_update_summary(plans: &[RunningServerUpdatePlan], release: &ReleaseInfo) { eprintln!("running herdr targets:"); for plan in plans { let capability = if server_supports_live_handoff(&plan.server) { "handoff supported" } else { "too old for handoff" }; eprintln!( " {}: v{} protocol {} ({})", plan.label(), version_label(plan.server.version.as_deref()), protocol_label(plan.server.protocol), capability ); } eprintln!( " update: v{} protocol {}", release.version, protocol_label(release.target_protocol) ); eprintln!(); } fn prompt_to_live_handoff_sessions_before_update( plans: &[&RunningServerUpdatePlan], release: &ReleaseInfo, requires_live_handoff: bool, ) -> Result { if !io::stdin().is_terminal() { if requires_live_handoff { return Err(format!( "one or more herdr targets are running and updating to v{} requires live server handoff; run `herdr update` from an interactive terminal, or stop those targets and run `herdr update` again", release.version )); } eprintln!( "herdr targets are running. updating the binary will not affect them until they restart." ); return Ok(false); } eprintln!( "herdr can hand off {} running target{} to the new server so pane processes keep running.", plans.len(), if plans.len() == 1 { "" } else { "s" } ); eprintln!("connected clients will disconnect during handoff and can attach again afterward."); loop { let prompt = if requires_live_handoff { "update and live-handoff supported targets to the new server? [Y/n] " } else { "live-handoff supported running targets after 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_live_handoff_before_update_response(&input) { return Ok(answer); } eprintln!("please answer y or n"); } } fn live_handoff_running_server_for_update( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, updated_exe: &Path, ) -> Result<(), String> { eprintln!( "asking {} {} to hand off live panes to the updated server...", plan.target_noun(), plan.label() ); live_handoff_server_via_api_for_update_at(plan.socket_path(), updated_exe, release)?; wait_for_server_handoff_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT, release)?; eprintln!( "live handoff complete for {} {}; pane processes should still be running.", plan.target_noun(), plan.label() ); Ok(()) } fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool { let protocol_matches = release .target_protocol .is_none_or(|protocol| status.protocol == Some(protocol)); let version_matches = status.version.as_deref() == Some(&release.version.to_string()); protocol_matches && version_matches } fn classify_failed_live_handoff_state_at( socket_path: &Path, release: &ReleaseInfo, ) -> FailedHandoffServerState { match crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT) { Ok(Some(status)) if runtime_matches_release(&status, release) => { FailedHandoffServerState::UpdatedServerRunning } Ok(Some(status)) => FailedHandoffServerState::OldServerRunning(status), Ok(None) => FailedHandoffServerState::NoServerResponding, Err(err) => FailedHandoffServerState::Unknown(err.to_string()), } } fn prompt_to_stop_old_server_after_failed_handoff( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, status: &crate::api::RuntimeStatus, ) -> Result { eprintln!( "live handoff failed, but {} {} is still running with your panes.", plan.target_noun(), plan.label() ); eprintln!( " server: v{} protocol {}", version_label(status.version.as_deref()), protocol_label(status.protocol) ); eprintln!( " installed: v{} protocol {}", release.version, protocol_label(release.target_protocol) ); eprintln!( "you can keep using the old server, or stop it now so the next `herdr` start uses v{}.", release.version ); eprintln!("stopping the old server will exit its pane processes."); if !io::stdin().is_terminal() { eprintln!( "not stopping the old server from a non-interactive update; run `{}` when you are ready.", plan.stop_command() ); return Ok(false); } loop { eprint!("stop the old server now? [y/N] "); 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); } match input.trim().to_ascii_lowercase().as_str() { "y" | "yes" => return Ok(true), "" | "n" | "no" => return Ok(false), _ => eprintln!("please answer y or n"), } } } fn recover_failed_live_handoff_for_update( plan: &RunningServerUpdatePlan, release: &ReleaseInfo, error: &str, ) -> Result { eprintln!( "live handoff failed for {} {}: {error}", plan.target_noun(), plan.label() ); match classify_failed_live_handoff_state_at(plan.socket_path(), release) { FailedHandoffServerState::UpdatedServerRunning => { eprintln!( "the updated server is running for {} {}.", plan.target_noun(), plan.label() ); Ok(RunningServerUpdateOutcome::LiveHandoffComplete) } FailedHandoffServerState::OldServerRunning(status) => { if prompt_to_stop_old_server_after_failed_handoff(plan, release, &status)? { stop_running_server_for_update(plan)?; Ok(RunningServerUpdateOutcome::FailedHandoffOldServerStopped) } else { Ok(RunningServerUpdateOutcome::FailedHandoffOldServerKept) } } FailedHandoffServerState::NoServerResponding => { if let Some(command) = plan.attach_command() { eprintln!( "no herdr server is responding for session {}. the binary was updated; run `{command}` to start v{}.", plan.label(), release.version ); } else { eprintln!( "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use v{}.", plan.socket_path().display(), release.version ); } Ok(RunningServerUpdateOutcome::FailedHandoffNoServer) } FailedHandoffServerState::Unknown(status_error) => { eprintln!( "herdr could not determine server state for {} {} after the failed handoff: {status_error}", plan.target_noun(), plan.label() ); eprintln!("{}", reconnect_or_stop_guidance(plan)); Ok(RunningServerUpdateOutcome::FailedHandoffUnknown) } } } fn reconnect_or_stop_guidance(plan: &RunningServerUpdatePlan) -> String { if let Some(command) = plan.attach_command() { format!( "if `{command}` does not reconnect cleanly, stop the old server with `{}` and run `{command}` again.", plan.stop_command() ) } else { format!( "if reconnecting with the same socket override does not work, stop the old server with `{}`.", plan.stop_command() ) } } fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{EmptyParams, Method}; send_server_update_method_at( socket_path, timeout, "update:server:stop", Method::ServerStop(EmptyParams::default()), "server stop", ) } fn send_server_update_method_at( socket_path: &Path, timeout: Duration, request_id: &str, method: crate::api::schema::Method, error_prefix: &str, ) -> Result<(), String> { use crate::api::schema::Request; let request = Request { id: request_id.into(), method, }; 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 {error_prefix} write timeout: {e}"))?; stream .set_read_timeout(Some(timeout)) .map_err(|e| format!("failed to set {error_prefix} 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 {error_prefix} request: {e}"))?; stream .write_all(b"\n") .map_err(|e| format!("failed to finish {error_prefix} request: {e}"))?; stream .flush() .map_err(|e| format!("failed to flush {error_prefix} 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 {error_prefix} response: {e}"))?; if read == 0 || line.trim().is_empty() { return Err(format!("empty {error_prefix} response")); } 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!("{error_prefix} failed: {error}")); } Ok(()) } #[cfg(test)] fn live_handoff_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> { use crate::api::schema::{Method, ServerLiveHandoffParams}; let params = ServerLiveHandoffParams::default(); send_server_update_method_at( socket_path, timeout, "update:server:live-handoff", Method::ServerLiveHandoff(params), "server live handoff", ) } fn live_handoff_server_via_api_for_release_at( socket_path: &Path, timeout: Duration, updated_exe: &Path, release: &ReleaseInfo, ) -> Result<(), String> { use crate::api::schema::{Method, ServerLiveHandoffParams}; let params = ServerLiveHandoffParams { import_exe: Some(updated_exe.display().to_string()), expected_protocol: release.target_protocol, expected_version: Some(release.version.to_string()), }; send_server_update_method_at( socket_path, timeout, "update:server:live-handoff", Method::ServerLiveHandoff(params), "server live handoff", ) } fn live_handoff_server_via_api_for_update_at( socket_path: &Path, updated_exe: &Path, release: &ReleaseInfo, ) -> Result<(), String> { live_handoff_server_via_api_for_release_at( socket_path, SERVER_HANDOFF_CONFIRM_TIMEOUT, updated_exe, release, ) } 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 stop_running_server_for_update(plan: &RunningServerUpdatePlan) -> Result<(), String> { eprintln!("stopping herdr {} {}...", plan.target_noun(), plan.label()); stop_server_via_api_at(plan.socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)?; wait_for_server_shutdown_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT)?; Ok(()) } fn wait_for_server_handoff_at( socket_path: &Path, timeout: Duration, release: &ReleaseInfo, ) -> Result<(), String> { wait_for_running_server_protocol_at( socket_path, timeout, release.target_protocol, Some(&release.version.to_string()), ) } fn wait_for_running_server_protocol_at( socket_path: &Path, timeout: Duration, expected_protocol: Option, expected_version: Option<&str>, ) -> Result<(), String> { let deadline = Instant::now() + timeout; loop { if let Some(status) = crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT) .map_err(|e| format!("failed to read server status after handoff: {e}"))? { let protocol_matches = expected_protocol.is_none_or(|protocol| status.protocol == Some(protocol)); let version_matches = expected_version.is_none_or(|version| status.version.as_deref() == Some(version)); if protocol_matches && version_matches { return Ok(()); } } if Instant::now() >= deadline { return Err(format!( "live handoff was requested, but no compatible server responded on {} after {} seconds", socket_path.display(), timeout.as_secs() )); } std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL); } } fn apply_running_session_update_decisions( release: &ReleaseInfo, updated_exe: &Path, decisions: Vec, ) -> Result, String> { let mut outcomes = Vec::new(); for decision in decisions { let outcome = match decision.action { RunningServerUpdateAction::None => RunningServerUpdateOutcome::RestartDeferred, RunningServerUpdateAction::StopOldServer => { stop_running_server_for_update(&decision.plan)?; RunningServerUpdateOutcome::Stopped } RunningServerUpdateAction::LiveHandoff => { match live_handoff_running_server_for_update(&decision.plan, release, updated_exe) { Ok(()) => RunningServerUpdateOutcome::LiveHandoffComplete, Err(err) => { recover_failed_live_handoff_for_update(&decision.plan, release, &err)? } } } }; let stop_command = decision.plan.stop_command(); outcomes.push(RunningSessionUpdateOutcome { session_label: decision.plan.label().to_string(), stop_command, attach_command: decision.plan.attach_command(), outcome, }); } Ok(outcomes) } fn print_running_session_update_outcomes( outcomes: &[RunningSessionUpdateOutcome], release: &ReleaseInfo, ) { if outcomes.is_empty() { eprintln!("run herdr again."); return; } for outcome in outcomes { match outcome.outcome { RunningServerUpdateOutcome::LiveHandoffComplete => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} was replaced; reconnect clients with `{command}`.", outcome.session_label ); } else { eprintln!( "server {} was replaced; reconnect using the same socket override.", outcome.session_label ); } } RunningServerUpdateOutcome::RestartDeferred => { if outcome.attach_command.is_some() { eprintln!( "session {} will use v{} after it restarts.", outcome.session_label, release.version ); } else { eprintln!( "server {} will use v{} after it restarts.", outcome.session_label, release.version ); } } RunningServerUpdateOutcome::Stopped | RunningServerUpdateOutcome::FailedHandoffOldServerStopped | RunningServerUpdateOutcome::FailedHandoffNoServer => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} is stopped; run `{command}` again.", outcome.session_label ); } else { eprintln!( "server {} is stopped; restart it with the same socket override.", outcome.session_label ); } } RunningServerUpdateOutcome::FailedHandoffOldServerKept => { if outcome.attach_command.is_some() { eprintln!( "session {} is still running with your panes; stop it later with `{}` to use v{}.", outcome.session_label, outcome.stop_command, release.version ); } else { eprintln!( "server {} is still running with your panes; stop it later with `{}` to use v{}.", outcome.session_label, outcome.stop_command, release.version ); } } RunningServerUpdateOutcome::FailedHandoffUnknown => { if let Some(command) = &outcome.attach_command { eprintln!( "session {} state is unclear; run `{command}`, or stop the old server with `{}` if reconnect fails.", outcome.session_label, outcome.stop_command ); } else { eprintln!( "server {} state is unclear; reconnect with the same socket override, or stop it with `{}` if reconnect fails.", outcome.session_label, outcome.stop_command ); } } } } } // --------------------------------------------------------------------------- // Installation manager detection // --------------------------------------------------------------------------- pub(crate) fn update_install_command() -> &'static str { if is_homebrew_managed_install() { HOMEBREW_UPDATE_COMMAND } else if is_nix_managed_install() { NIX_UPDATE_COMMAND } else { HERDR_UPDATE_COMMAND } } fn is_homebrew_managed_install() -> bool { let Ok(current_exe) = env::current_exe() else { return false; }; if is_homebrew_managed_exe_path(¤t_exe) { return true; } current_exe .canonicalize() .is_ok_and(|path| is_homebrew_managed_exe_path(&path)) } fn is_nix_managed_install() -> bool { let Ok(current_exe) = env::current_exe() else { return false; }; if is_nix_store_exe_path(¤t_exe) { return true; } current_exe .canonicalize() .is_ok_and(|path| is_nix_store_exe_path(&path)) } fn is_nix_store_exe_path(path: &Path) -> bool { path.starts_with("/nix/store") } fn is_homebrew_managed_exe_path(path: &Path) -> bool { homebrew_cellar_keg_root(path).is_some() } fn homebrew_cellar_keg_root(path: &Path) -> Option { if path.file_name()? != "herdr" { return None; } let bin_dir = path.parent()?; if bin_dir.file_name()? != "bin" { return None; } let version_dir = bin_dir.parent()?; let formula_dir = version_dir.parent()?; if formula_dir.file_name()? != "herdr" { return None; } let cellar_dir = formula_dir.parent()?; if cellar_dir.file_name()? != "Cellar" { return None; } Some(version_dir.to_path_buf()) } // --------------------------------------------------------------------------- // GitHub star prompt // --------------------------------------------------------------------------- #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] struct StarPromptState { #[serde(default)] prompts_shown: u32, #[serde(default)] successful_updates_since_prompt: u32, #[serde(default)] known_starred: bool, } #[derive(Debug, PartialEq, Eq)] enum GithubStarStatus { Starred, NotStarred, Unknown, } fn star_prompt_state_path() -> PathBuf { crate::config::state_dir().join(STAR_PROMPT_STATE_FILE) } fn load_star_prompt_state_from_path(path: &Path) -> StarPromptState { let Ok(content) = fs::read_to_string(path) else { return StarPromptState::default(); }; serde_json::from_str(&content).unwrap_or_default() } fn save_star_prompt_state_to_path(path: &Path, state: &StarPromptState) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?; let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id())); fs::write(&tmp_path, json)?; if let Err(err) = fs::rename(&tmp_path, path) { let _ = fs::remove_file(&tmp_path); return Err(err); } Ok(()) } fn star_prompt_due(state: &StarPromptState) -> bool { if state.known_starred { return false; } if state.prompts_shown == 0 { return true; } if state.prompts_shown >= STAR_PROMPT_MAX_PROMPTS { return false; } let interval_index = state.prompts_shown.saturating_sub(1) as usize; let Some(interval) = STAR_PROMPT_INTERVAL_UPDATES.get(interval_index) else { return false; }; state.successful_updates_since_prompt.saturating_add(1) >= *interval } fn record_successful_update_without_star_prompt(state: &mut StarPromptState) { state.successful_updates_since_prompt = state.successful_updates_since_prompt.saturating_add(1); } fn record_star_prompt_shown(state: &mut StarPromptState) { state.prompts_shown = state.prompts_shown.saturating_add(1); state.successful_updates_since_prompt = 0; } fn command_status_succeeds_with_timeout(command: &mut Command, timeout: Duration) -> bool { let Ok(mut child) = command.spawn() else { return false; }; let deadline = Instant::now() + timeout; loop { match child.try_wait() { Ok(Some(status)) => return status.success(), Ok(None) if Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(50)); } Ok(None) => { let _ = child.kill(); let _ = child.wait(); return false; } Err(_) => return false, } } } fn command_output_with_timeout( command: &mut Command, timeout: Duration, ) -> io::Result> { let mut child = command.spawn()?; let deadline = Instant::now() + timeout; loop { match child.try_wait()? { Some(_) => return child.wait_with_output().map(Some), None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), None => { let _ = child.kill(); let _ = child.wait(); return Ok(None); } } } } fn command_succeeds(command: &str, args: &[&str]) -> bool { let mut command = Command::new(command); command .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); command_status_succeeds_with_timeout(&mut command, GH_COMMAND_TIMEOUT) } fn gh_cli_available() -> bool { command_succeeds("gh", &["--version"]) } fn gh_auth_succeeds() -> bool { command_succeeds("gh", &["auth", "status", "--hostname", "github.com"]) } fn gh_viewer_star_status() -> GithubStarStatus { let mut command = Command::new("gh"); command .args([ "repo", "view", STAR_PROMPT_REPO, "--json", "viewerHasStarred", "--jq", ".viewerHasStarred", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); let Ok(Some(output)) = command_output_with_timeout(&mut command, GH_COMMAND_TIMEOUT) else { return GithubStarStatus::Unknown; }; if !output.status.success() { return GithubStarStatus::Unknown; } match String::from_utf8_lossy(&output.stdout).trim() { "true" => GithubStarStatus::Starred, "false" => GithubStarStatus::NotStarred, _ => GithubStarStatus::Unknown, } } fn parse_star_prompt_response(input: &str) -> Option { match input.trim().to_ascii_lowercase().as_str() { "" | "y" | "yes" => Some(true), "n" | "no" => Some(false), _ => None, } } fn prompt_to_star_repository() -> io::Result { loop { eprint!("If herdr has been useful, would you like to star it? [Y/n] "); io::stderr().flush()?; let mut input = String::new(); if io::stdin().read_line(&mut input)? == 0 { return Ok(false); } if let Some(answer) = parse_star_prompt_response(&input) { return Ok(answer); } eprintln!("please answer y or n."); } } fn star_repository_with_gh() -> bool { let endpoint = format!("/user/starred/{STAR_PROMPT_REPO}"); let mut command = Command::new("gh"); command .args(["api", "--method", "PUT", endpoint.as_str(), "--silent"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); if command_status_succeeds_with_timeout(&mut command, GH_COMMAND_TIMEOUT) { eprintln!("starred {STAR_PROMPT_REPO}. thank you."); true } else { false } } fn maybe_offer_star_after_successful_update() { if !io::stdin().is_terminal() || !io::stderr().is_terminal() { return; } let path = star_prompt_state_path(); let mut state = load_star_prompt_state_from_path(&path); if state.known_starred { return; } if !star_prompt_due(&state) { record_successful_update_without_star_prompt(&mut state); if let Err(err) = save_star_prompt_state_to_path(&path, &state) { tracing::warn!(err = %err, "failed to save GitHub star prompt state"); } return; } if !gh_cli_available() || !gh_auth_succeeds() { return; } match gh_viewer_star_status() { GithubStarStatus::NotStarred => {} GithubStarStatus::Starred => { state.known_starred = true; if let Err(err) = save_star_prompt_state_to_path(&path, &state) { tracing::warn!(err = %err, "failed to save GitHub star prompt state"); } return; } GithubStarStatus::Unknown => return, } record_star_prompt_shown(&mut state); if let Err(err) = save_star_prompt_state_to_path(&path, &state) { tracing::warn!(err = %err, "failed to save GitHub star prompt state"); return; } match prompt_to_star_repository() { Ok(true) => { if star_repository_with_gh() { state.known_starred = true; if let Err(err) = save_star_prompt_state_to_path(&path, &state) { tracing::warn!(err = %err, "failed to save GitHub star prompt state"); } } } Ok(false) => {} Err(err) => tracing::warn!(err = %err, "failed to read GitHub star prompt response"), } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Manual self-update command (`herdr update`). pub fn self_update() -> Result { if is_homebrew_managed_install() { return Err(format!( "self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`" )); } if is_nix_managed_install() { return Err( "self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(), ); } 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_plans = plan_running_server_updates(&release)?; let server_update_decisions = confirm_running_server_update_action(running_server_plans, &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(); install_downloaded_update(downloaded_update)?; let server_update_outcomes = apply_running_session_update_decisions(&release, &updated_exe, server_update_decisions)?; eprintln!("updated to v{}", release.version); print_outdated_integration_notice_with_updated_binary(&updated_exe); print_running_session_update_outcomes(&server_update_outcomes, &release); maybe_offer_star_after_successful_update(); 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(), install_command: update_install_command().to_string(), }); } return; } if is_homebrew_managed_install() { auto_update_homebrew(events); return; } let nix_managed_install = is_nix_managed_install(); 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 install_command = if nix_managed_install { NIX_UPDATE_COMMAND } else { HERDR_UPDATE_COMMAND }; let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: release.version.to_string(), install_command: install_command.to_string(), }); } fn auto_update_homebrew(events: tokio::sync::mpsc::Sender) { let version = match check_homebrew_latest() { Ok(Some(version)) => version, Ok(None) => return, Err(err) => { crate::logging::update_check_failed(&err); return; } }; crate::logging::update_available(&version.to_string()); let notes_body = homebrew_release_notes_body(&version); if let Err(e) = crate::release_notes::save_pending(&version.to_string(), ¬es_body) { tracing::warn!("failed to save pending release notes: {e}"); } tracing::info!( "auto-update check: v{} available through Homebrew, waiting for explicit install", version ); let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { version: version.to_string(), install_command: HOMEBREW_UPDATE_COMMAND.to_string(), }); } fn homebrew_release_notes_body(version: &Version) -> String { if let Ok(manifest) = fetch_update_manifest() { if let Some(metadata) = manifest.metadata_for_version(version) { let notes_body = metadata.notes_body(); if !notes_body.is_empty() { handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref()); return notes_body; } } } format!("### Changed\n- v{version} is available through Homebrew.") } // --------------------------------------------------------------------------- // 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::sync::{Mutex, OnceLock}; use std::thread; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } 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) } fn spawn_status_server_once( path: &Path, version: &str, protocol: u32, ) -> thread::JoinHandle<()> { let listener = UnixListener::bind(path).unwrap(); let version = version.to_string(); 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("\"method\":\"ping\"")); let response = format!( r#"{{"id":"runtime:status","result":{{"type":"pong","version":"{version}","protocol":{protocol},"capabilities":{{"live_handoff":true}}}}}}"# ); stream.write_all(response.as_bytes()).unwrap(); stream.write_all(b"\n").unwrap(); stream.flush().unwrap(); }) } fn fake_release(version: &str, target_protocol: Option) -> ReleaseInfo { ReleaseInfo { version: Version::parse(version).unwrap(), target_protocol, download_url: "https://example.com/herdr".to_string(), notes_body: "### Changed\n- One".to_string(), } } fn set_test_config_home(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let short_name: String = name.chars().take(4).collect(); let dir = PathBuf::from(format!( "/tmp/hu-{short_name}-{}-{nanos}", std::process::id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); std::env::set_var("XDG_CONFIG_HOME", &dir); dir } #[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 homebrew_cellar_path_is_detected() { let path = Path::new("/opt/homebrew/Cellar/herdr/0.5.9/bin/herdr"); assert!(is_homebrew_managed_exe_path(path)); assert_eq!( homebrew_cellar_keg_root(path).unwrap(), PathBuf::from("/opt/homebrew/Cellar/herdr/0.5.9") ); } #[test] fn homebrew_linux_cellar_path_is_detected() { let path = Path::new("/home/linuxbrew/.linuxbrew/Cellar/herdr/0.5.9/bin/herdr"); assert!(is_homebrew_managed_exe_path(path)); } #[test] fn homebrew_opt_path_requires_canonicalized_cellar_target() { let path = Path::new("/opt/homebrew/opt/herdr/bin/herdr"); assert!(!is_homebrew_managed_exe_path(path)); } #[test] fn non_homebrew_path_is_not_detected() { let path = Path::new("/usr/local/bin/herdr"); assert!(!is_homebrew_managed_exe_path(path)); } #[test] fn nix_store_path_is_detected() { let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr"); assert!(is_nix_store_exe_path(path)); } #[test] fn non_nix_store_path_is_not_detected() { let path = Path::new("/usr/local/bin/herdr"); assert!(!is_nix_store_exe_path(path)); } #[test] fn parse_homebrew_formula_stable_version_reads_versions_stable() { let version = parse_homebrew_formula_stable_version( br#"{"versions":{"stable":"0.5.10","head":"HEAD","bottle":true}}"#, ) .unwrap(); assert_eq!(version, Version::parse("0.5.10").unwrap()); } #[test] fn fake_release_notes_default_to_real_large_changelog_section() { let _guard = env_lock().lock().unwrap(); 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() { let _guard = env_lock().lock().unwrap(); 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_live_handoff_before_update_response_defaults_yes_for_blank() { assert_eq!(parse_live_handoff_before_update_response(""), Some(true)); assert_eq!(parse_live_handoff_before_update_response("\n"), Some(true)); assert_eq!(parse_live_handoff_before_update_response("y"), Some(true)); assert_eq!(parse_live_handoff_before_update_response("yes"), Some(true)); assert_eq!(parse_live_handoff_before_update_response("n"), Some(false)); assert_eq!(parse_live_handoff_before_update_response("no"), Some(false)); assert_eq!(parse_live_handoff_before_update_response("later"), None); } #[test] fn update_requires_live_handoff_when_target_protocol_differs_or_unknown() { let server = crate::api::RuntimeStatus { version: Some("0.5.5".to_string()), protocol: Some(2), capabilities: None, }; 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_live_handoff(&server, &compatible_release)); assert!(update_requires_live_handoff(&server, &incompatible_release)); assert!(update_requires_live_handoff(&server, &unknown_release)); } #[test] fn plain_update_targets_all_running_sessions() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("all-sessions"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let default_socket = crate::session::api_socket_path_for(None); let work_socket = crate::session::api_socket_path_for(Some("work")); fs::create_dir_all(default_socket.parent().unwrap()).unwrap(); fs::create_dir_all(work_socket.parent().unwrap()).unwrap(); let default_listener = UnixListener::bind(&default_socket).unwrap(); let work_listener = UnixListener::bind(&work_socket).unwrap(); let mut targets = running_update_targets().unwrap(); targets.sort_by(|left, right| left.label.cmp(&right.label)); drop(default_listener); drop(work_listener); let _ = fs::remove_dir_all(config_home); std::env::remove_var("XDG_CONFIG_HOME"); assert_eq!(targets.len(), 2); assert_eq!(targets[0].label, crate::session::DEFAULT_SESSION_NAME); assert_eq!(targets[0].name, None); assert_eq!(targets[1].label, "work"); assert_eq!(targets[1].name.as_deref(), Some("work")); } #[test] fn explicit_session_update_targets_only_that_session() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("explicit-session"); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/ignored-herdr.sock"); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let args = vec![ "herdr".to_string(), "--session".to_string(), "work".to_string(), "update".to_string(), ]; let _ = crate::session::configure_from_args(&args).unwrap(); let targets = running_update_targets().unwrap(); let expected_socket = crate::session::api_socket_path_for(Some("work")); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); std::env::remove_var("XDG_CONFIG_HOME"); crate::session::clear_explicit_session_for_test(); let _ = fs::remove_dir_all(config_home); assert_eq!(targets.len(), 1); assert_eq!(targets[0].label, "work"); assert_eq!(targets[0].name.as_deref(), Some("work")); assert_eq!(targets[0].socket_path, expected_socket); } #[test] fn socket_override_update_targets_socket_not_env_session() { let _guard = env_lock().lock().unwrap(); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/custom-herdr.sock"); std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); crate::session::clear_explicit_session_for_test(); let targets = running_update_targets().unwrap(); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); assert_eq!(targets.len(), 1); assert_eq!(targets[0].name, None); assert_eq!( targets[0].socket_path, PathBuf::from("/tmp/custom-herdr.sock") ); assert!(targets[0] .stop_command .contains(crate::api::SOCKET_PATH_ENV_VAR)); } #[test] fn plain_update_errors_when_named_session_has_client_socket_without_status_api() { let _guard = env_lock().lock().unwrap(); let config_home = set_test_config_home("client-only-session"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); let work_client_socket = crate::session::client_socket_path_for(Some("work")); fs::create_dir_all(work_client_socket.parent().unwrap()).unwrap(); let work_client_listener = UnixListener::bind(&work_client_socket).unwrap(); let release = fake_release("9.8.7", Some(77)); let err = plan_running_server_updates(&release).unwrap_err(); drop(work_client_listener); let _ = fs::remove_dir_all(config_home); std::env::remove_var("XDG_CONFIG_HOME"); assert!( err.contains("work") && err.contains("status API did not respond"), "unexpected error: {err}" ); assert!( err.contains("herdr session stop work"), "unexpected error: {err}" ); } #[test] fn failed_handoff_classification_detects_updated_server() { let socket_path = unique_test_socket_path("handoff-updated-status"); let handle = spawn_status_server_once(&socket_path, "9.8.7", 77); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert_eq!(state, FailedHandoffServerState::UpdatedServerRunning); } #[test] fn failed_handoff_classification_detects_old_server() { let socket_path = unique_test_socket_path("handoff-old-status"); let handle = spawn_status_server_once(&socket_path, "0.6.2", 76); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); let _ = handle.join(); let _ = fs::remove_file(&socket_path); match state { FailedHandoffServerState::OldServerRunning(status) => { assert_eq!(status.version.as_deref(), Some("0.6.2")); assert_eq!(status.protocol, Some(76)); } other => panic!("unexpected state: {other:?}"), } } #[test] fn failed_handoff_classification_detects_missing_server() { let socket_path = unique_test_socket_path("handoff-missing-status"); let release = fake_release("9.8.7", Some(77)); let state = classify_failed_live_handoff_state_at(&socket_path, &release); assert_eq!(state, FailedHandoffServerState::NoServerResponding); } #[test] fn noninteractive_update_requires_handoff_fails_before_install() { let _guard = env_lock().lock().unwrap(); assert!( !io::stdin().is_terminal(), "this test relies on noninteractive test stdin" ); std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); crate::session::clear_explicit_session_for_test(); let server = crate::api::RuntimeStatus { version: Some("0.5.5".to_string()), protocol: Some(2), capabilities: None, }; let release = ReleaseInfo { version: Version::parse("0.5.6").unwrap(), target_protocol: Some(3), download_url: "https://example.com/herdr".to_string(), notes_body: "### Changed\n- One".to_string(), }; let plan = RunningServerUpdatePlan { target: RunningUpdateTarget { name: Some("work".to_string()), label: "work".to_string(), stop_command: "herdr session stop work".to_string(), attach_command: Some("herdr session attach work".to_string()), socket_path: crate::session::api_socket_path_for(Some("work")), client_socket_path: crate::session::client_socket_path_for(Some("work")), must_be_running: true, }, requires_live_handoff: true, server, }; let err = prompt_to_live_handoff_sessions_before_update(&[&plan], &release, true).unwrap_err(); assert!( err.contains("requires live server handoff"), "unexpected error: {err}" ); assert!( err.contains("run `herdr update` from an interactive terminal"), "unexpected error: {err}" ); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); } #[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 live_handoff_server_via_api_sends_handoff_request() { let socket_path = unique_test_socket_path("handoff-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.live_handoff")); stream .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let result = live_handoff_server_via_api_at(&socket_path, Duration::from_millis(200)); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected handoff request to succeed: {result:?}" ); } #[test] fn update_live_handoff_request_names_import_binary_and_expected_release() { let socket_path = unique_test_socket_path("handoff-update-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(); let value: serde_json::Value = serde_json::from_str(&request).unwrap(); assert_eq!(value["method"], "server.live_handoff"); assert_eq!(value["params"]["import_exe"], "/tmp/herdr-new"); assert_eq!(value["params"]["expected_protocol"], 77); assert_eq!(value["params"]["expected_version"], "9.8.7"); stream .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n") .unwrap(); stream.flush().unwrap(); }); let release = ReleaseInfo { version: Version::parse("9.8.7").unwrap(), target_protocol: Some(77), download_url: "https://example.com/herdr".to_string(), notes_body: "### Changed\n- One".to_string(), }; let result = live_handoff_server_via_api_for_release_at( &socket_path, Duration::from_millis(200), Path::new("/tmp/herdr-new"), &release, ); let _ = handle.join(); let _ = fs::remove_file(&socket_path); assert!( result.is_ok(), "expected handoff 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 star_prompt_response_defaults_yes() { assert_eq!(parse_star_prompt_response(""), Some(true)); assert_eq!(parse_star_prompt_response("\n"), Some(true)); assert_eq!(parse_star_prompt_response("y"), Some(true)); assert_eq!(parse_star_prompt_response("yes"), Some(true)); assert_eq!(parse_star_prompt_response("n"), Some(false)); assert_eq!(parse_star_prompt_response("no"), Some(false)); assert_eq!(parse_star_prompt_response("later"), None); } #[test] fn star_prompt_is_due_five_times_with_increasing_intervals() { let mut state = StarPromptState::default(); assert!(star_prompt_due(&state)); record_star_prompt_shown(&mut state); assert_eq!(state.prompts_shown, 1); assert_eq!(state.successful_updates_since_prompt, 0); for interval in STAR_PROMPT_INTERVAL_UPDATES { for update in 1..interval { assert!( !star_prompt_due(&state), "prompt was early at update {update}/{interval}" ); record_successful_update_without_star_prompt(&mut state); } assert!( star_prompt_due(&state), "prompt was not due after {interval} updates" ); record_star_prompt_shown(&mut state); assert_eq!(state.successful_updates_since_prompt, 0); } assert_eq!(state.prompts_shown, STAR_PROMPT_MAX_PROMPTS); for _ in 0..20 { assert!(!star_prompt_due(&state)); record_successful_update_without_star_prompt(&mut state); } } #[test] fn star_prompt_is_never_due_after_known_starred() { let mut state = StarPromptState { prompts_shown: 1, successful_updates_since_prompt: 99, known_starred: true, }; assert!(!star_prompt_due(&state)); record_successful_update_without_star_prompt(&mut state); assert!(!star_prompt_due(&state)); } #[test] fn star_prompt_state_round_trips() { let path = std::env::temp_dir().join(format!( "herdr-star-prompt-{}-{}.json", std::process::id(), "round-trip" )); let state = StarPromptState { prompts_shown: 2, successful_updates_since_prompt: 1, known_starred: false, }; save_star_prompt_state_to_path(&path, &state).unwrap(); assert_eq!(load_star_prompt_state_from_path(&path), state); let _ = fs::remove_file(path); } #[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\ \"announcement\": {\n\ \"id\": \"keymap-v2\",\n\ \"title\": \"Keymap changes\",\n\ \"body\": \"### Heads up\\n- Defaults changed\"\n\ },\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 .metadata_for_version(&Version::parse("0.2.0").unwrap()) .expect("metadata") .notes_body(), "### Changed\n- One" ); assert_eq!( manifest .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("keymap-v2") ); assert_eq!( manifest.download_url_for("linux", "x86_64").as_deref(), Some("https://example.com/herdr-linux-x86_64") ); } #[test] fn update_manifest_reads_archived_release_metadata() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Three", "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": { "0.2.0": { "notes": "### Changed\n- Two", "announcement": { "id": "two", "title": "Two", "body": "### Two" } } } }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); let version = Version::parse("0.2.0").unwrap(); let metadata = manifest.metadata_for_version(&version).expect("metadata"); assert_eq!(metadata.notes_body(), "### Changed\n- Two"); assert_eq!( metadata .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("two") ); } #[test] fn update_manifest_root_metadata_wins_for_latest_version() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Root", "announcement": { "id": "root", "title": "Root", "body": "### Root" }, "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": { "0.3.0": { "notes": "### Changed\n- Stale", "announcement": { "id": "stale", "title": "Stale", "body": "### Stale" } } } }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); let version = Version::parse("0.3.0").unwrap(); let metadata = manifest.metadata_for_version(&version).expect("metadata"); assert_eq!(metadata.notes_body(), "### Changed\n- Root"); assert_eq!( metadata .announcement .as_ref() .and_then(|announcement| announcement.get("id")) .and_then(serde_json::Value::as_str), Some("root") ); } #[test] fn update_manifest_ignores_malformed_releases_container() { let json = r####"{ "version": "0.3.0", "protocol": 4, "notes": "### Changed\n- Root", "assets": { "linux_x86_64": "https://example.com/unused" }, "releases": [] }"####; let manifest: UpdateManifest = serde_json::from_str(json).unwrap(); assert!(manifest.releases.is_empty()); assert_eq!( manifest .metadata_for_version(&Version::parse("0.3.0").unwrap()) .expect("metadata") .notes_body(), "### Changed\n- Root" ); } #[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 invalid_manifest_announcement_does_not_block_release_info() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); let json = format!( r####"{{ "version": "99.99.99", "protocol": 4, "notes": "### Changed\n- One", "announcement": {{ "id": 123, "title": "Keymap changes", "body": "### Heads up\n- Defaults changed" }}, "assets": {{ "{asset_key}": "https://example.com/herdr" }} }}"#### ); let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); handle_manifest_announcement(&manifest.version, manifest.announcement.as_ref()); let release = release_info_from_manifest(&manifest) .unwrap() .expect("release info"); assert_eq!(release.version, Version::parse("99.99.99").unwrap()); assert_eq!(release.download_url, "https://example.com/herdr"); } #[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 .metadata_for_version(&Version::parse(&manifest.version).unwrap()) .expect("metadata") .notes_body() .is_empty()); // website/latest.json describes the latest released binaries, not the // current unreleased checkout. Its protocol is updated by the release // flow together with the release assets. assert!(manifest.protocol.is_some()); assert_eq!(manifest.assets.len(), 4); assert!(manifest.releases.contains_key(&manifest.version)); 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}" ); } } }