diff --git a/clients/agent-runtime/README.md b/clients/agent-runtime/README.md index 7e0271c84..6b77265f7 100755 --- a/clients/agent-runtime/README.md +++ b/clients/agent-runtime/README.md @@ -112,6 +112,8 @@ corvus integrations info Telegram # Manage background service corvus service install +corvus service install --linger on # Linux: keep running after logout/reboot (user service + linger) +corvus service restart # useful after binary updates corvus service status # Migrate memory from OpenClaw (safe preview first) @@ -119,6 +121,9 @@ corvus migrate openclaw --dry-run corvus migrate openclaw ``` +Corvus also checks for newer releases in `agent`, `daemon`, and `status` commands. +Set `CORVUS_DISABLE_UPDATE_CHECK=1` to disable update notifications. + > **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`). @@ -447,7 +452,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples. | `gateway` | Start webhook server (default: `127.0.0.1:8080`) | | `gateway --port 0` | Random port mode | | `daemon` | Start long-running autonomous runtime | -| `service install/start/stop/status/uninstall` | Manage user-level background service | +| `service install/start/restart/stop/status/uninstall` | Manage background service lifecycle | | `doctor` | Diagnose daemon/scheduler/channel freshness | | `status` | Show full system status | | `channel doctor` | Run health checks for configured channels | diff --git a/clients/agent-runtime/src/daemon/mod.rs b/clients/agent-runtime/src/daemon/mod.rs index 9eec82ce2..50da1e25b 100755 --- a/clients/agent-runtime/src/daemon/mod.rs +++ b/clients/agent-runtime/src/daemon/mod.rs @@ -149,6 +149,7 @@ where tokio::spawn(async move { let mut backoff = initial_backoff_secs.max(1); let max_backoff = max_backoff_secs.max(backoff); + let mut gateway_port_conflict_logged = false; loop { crate::health::mark_component_ok(name); @@ -160,8 +161,17 @@ where backoff = initial_backoff_secs.max(1); } Err(e) => { - crate::health::mark_component_error(name, e.to_string()); - tracing::error!("Daemon component '{name}' failed: {e}"); + let is_gateway_addr_in_use = + name == "gateway" && is_addr_in_use_error(&e); + let err_str = e.to_string(); + crate::health::mark_component_error(name, &err_str); + tracing::error!("Daemon component '{name}' failed: {err_str}"); + if is_gateway_addr_in_use && !gateway_port_conflict_logged { + gateway_port_conflict_logged = true; + tracing::warn!( + "Gateway port is already in use. This usually means another daemon/gateway instance is already running. If this happened after an upgrade, run `corvus service restart` instead of starting a second daemon process." + ); + } } } @@ -173,6 +183,13 @@ where }) } +fn is_addr_in_use_error(error: &anyhow::Error) -> bool { + error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(|io_error| io_error.kind() == std::io::ErrorKind::AddrInUse) +} + async fn run_heartbeat_worker(config: Config) -> Result<()> { let observer: std::sync::Arc = std::sync::Arc::from(crate::observability::create_observer(&config.observability)); @@ -227,6 +244,23 @@ mod tests { use super::*; use tempfile::TempDir; + struct HealthComponentGuard { + name: &'static str, + } + + impl HealthComponentGuard { + fn new(name: &'static str) -> Self { + crate::health::clear_component(name); + Self { name } + } + } + + impl Drop for HealthComponentGuard { + fn drop(&mut self) { + crate::health::clear_component(self.name); + } + } + fn test_config(tmp: &TempDir) -> Config { let config = Config { workspace_dir: tmp.path().join("workspace"), @@ -284,6 +318,30 @@ mod tests { .contains("component exited unexpectedly")); } + #[tokio::test] + async fn supervisor_logs_hint_on_gateway_addr_in_use() { + let _health_guard = HealthComponentGuard::new("gateway"); + let handle = spawn_component_supervisor("gateway", 1, 1, || async { + Err(anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "Address already in use", + ))) + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + handle.abort(); + let _ = handle.await; + + let snapshot = crate::health::snapshot_json(); + let component = &snapshot["components"]["gateway"]; + assert_eq!(component["status"], "error"); + assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1); + assert!(component["last_error"] + .as_str() + .unwrap_or("") + .contains("Address already in use")); + } + #[test] fn detects_no_supervised_channels() { let config = Config::default(); diff --git a/clients/agent-runtime/src/gateway/mod.rs b/clients/agent-runtime/src/gateway/mod.rs index 5a3000ffb..46af767eb 100755 --- a/clients/agent-runtime/src/gateway/mod.rs +++ b/clients/agent-runtime/src/gateway/mod.rs @@ -1100,7 +1100,6 @@ mod tests { assert!(text.contains("corvus_heartbeat_ticks_total 1")); } - #[test] fn extract_bearer_token_accepts_case_insensitive_scheme_and_trims() { let mut headers = HeaderMap::new(); @@ -1118,10 +1117,7 @@ mod tests { let mut headers = HeaderMap::new(); let oversized = "x".repeat(TOKEN_MAX_LEN + 1); let auth = format!("Bearer {oversized}"); - headers.insert( - header::AUTHORIZATION, - HeaderValue::from_str(&auth).unwrap(), - ); + headers.insert(header::AUTHORIZATION, HeaderValue::from_str(&auth).unwrap()); assert!(extract_bearer_token(&headers).is_none()); } diff --git a/clients/agent-runtime/src/health/mod.rs b/clients/agent-runtime/src/health/mod.rs index 2926c213f..0870776ae 100755 --- a/clients/agent-runtime/src/health/mod.rs +++ b/clients/agent-runtime/src/health/mod.rs @@ -82,6 +82,11 @@ pub fn bump_component_restart(component: &str) { }); } +pub fn clear_component(component: &str) { + let mut map = registry().components.lock(); + map.remove(component); +} + pub fn snapshot() -> HealthSnapshot { let components = registry().components.lock().clone(); diff --git a/clients/agent-runtime/src/lib.rs b/clients/agent-runtime/src/lib.rs index 99e3e51f9..b4a9c5323 100755 --- a/clients/agent-runtime/src/lib.rs +++ b/clients/agent-runtime/src/lib.rs @@ -35,7 +35,7 @@ dead_code )] -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; pub mod agent; @@ -71,13 +71,26 @@ pub mod util; pub use config::Config; +#[derive(Debug, Clone, Copy, ValueEnum, Serialize, Deserialize, PartialEq, Eq)] +pub enum ServiceLingerMode { + Keep, + On, + Off, +} + /// Service management subcommands #[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ServiceCommands { /// Install daemon service unit for auto-start and restart - Install, + Install { + /// Linux only: keep user service active without an interactive session + #[arg(long, value_enum, default_value_t = ServiceLingerMode::Keep)] + linger: ServiceLingerMode, + }, /// Start daemon service Start, + /// Restart daemon service + Restart, /// Stop daemon service Stop, /// Check daemon service status diff --git a/clients/agent-runtime/src/main.rs b/clients/agent-runtime/src/main.rs index 091ed3b79..1e861ba9c 100644 --- a/clients/agent-runtime/src/main.rs +++ b/clients/agent-runtime/src/main.rs @@ -36,6 +36,7 @@ use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use dialoguer::{Input, Password}; use serde::{Deserialize, Serialize}; +use std::time::Duration; use tracing::{info, warn}; use tracing_subscriber::{fmt, EnvFilter}; @@ -70,12 +71,15 @@ mod skillforge; mod skills; mod tools; mod tunnel; +mod update; mod util; use config::Config; -// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. -pub use corvus::{HardwareCommands, PeripheralCommands}; +// Re-export so binary modules can use crate::...Commands from the library crate. +pub use corvus::{ + HardwareCommands, PeripheralCommands, ServiceCommands, ServiceLingerMode, +}; /// `Corvus` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] @@ -88,20 +92,6 @@ struct Cli { command: Commands, } -#[derive(Subcommand, Debug)] -enum ServiceCommands { - /// Install daemon service unit for auto-start and restart - Install, - /// Start daemon service - Start, - /// Stop daemon service - Stop, - /// Check daemon service status - Status, - /// Uninstall daemon service unit - Uninstall, -} - #[derive(Subcommand, Debug)] enum Commands { /// Initialize your workspace and configuration @@ -591,9 +581,12 @@ async fn main() -> Result<()> { model, temperature, peripheral, - } => agent::run(config, message, provider, model, temperature, peripheral) - .await - .map(|_| ()), + } => { + maybe_print_update_notice_bounded(&config).await; + agent::run(config, message, provider, model, temperature, peripheral) + .await + .map(|_| ()) + } Commands::Gateway { port, host } => { let port = port.unwrap_or(config.gateway.port); @@ -607,6 +600,10 @@ async fn main() -> Result<()> { } Commands::Daemon { port, host } => { + let update_config = config.clone(); + tokio::spawn(async move { + update::maybe_print_update_notice(&update_config).await; + }); let port = port.unwrap_or(config.gateway.port); let host = host.unwrap_or_else(|| config.gateway.host.clone()); if port == 0 { @@ -618,6 +615,7 @@ async fn main() -> Result<()> { } Commands::Status => { + maybe_print_update_notice_bounded(&config).await; println!("🦀 Corvus Status"); println!(); println!("Version: {}", env!("CARGO_PKG_VERSION")); @@ -881,6 +879,18 @@ async fn main() -> Result<()> { } } +async fn maybe_print_update_notice_bounded(config: &Config) { + if tokio::time::timeout( + Duration::from_millis(500), + update::maybe_print_update_notice(config), + ) + .await + .is_err() + { + tracing::debug!("Update notice check timed out after 500ms"); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct PendingOpenAiLogin { profile: String, diff --git a/clients/agent-runtime/src/service/mod.rs b/clients/agent-runtime/src/service/mod.rs index 2e6c27209..cbeb3be2f 100755 --- a/clients/agent-runtime/src/service/mod.rs +++ b/clients/agent-runtime/src/service/mod.rs @@ -13,26 +13,44 @@ fn windows_task_name() -> &'static str { pub fn handle_command(command: &crate::ServiceCommands, config: &Config) -> Result<()> { match command { - crate::ServiceCommands::Install => install(config), + crate::ServiceCommands::Install { linger } => install(config, *linger), crate::ServiceCommands::Start => start(config), + crate::ServiceCommands::Restart => restart(config), crate::ServiceCommands::Stop => stop(config), crate::ServiceCommands::Status => status(config), crate::ServiceCommands::Uninstall => uninstall(config), } } -fn install(config: &Config) -> Result<()> { +fn install(config: &Config, linger: crate::ServiceLingerMode) -> Result<()> { if cfg!(target_os = "macos") { + maybe_warn_unsupported_linger_mode(linger); install_macos(config) } else if cfg!(target_os = "linux") { - install_linux(config) + install_linux(config)?; + apply_linux_linger_mode(linger) } else if cfg!(target_os = "windows") { + maybe_warn_unsupported_linger_mode(linger); install_windows(config) } else { anyhow::bail!("Service management is supported on macOS and Linux only"); } } +fn restart(config: &Config) -> Result<()> { + if cfg!(target_os = "linux") { + if run_checked(Command::new("systemctl").args(["--user", "restart", "corvus.service"])) + .is_ok() + { + println!("✅ Service restarted"); + return Ok(()); + } + } + + stop(config)?; + start(config) +} + fn start(config: &Config) -> Result<()> { if cfg!(target_os = "macos") { let plist = macos_service_file()?; @@ -105,6 +123,18 @@ fn status(config: &Config) -> Result<()> { run_capture(Command::new("systemctl").args(["--user", "is-active", "corvus.service"])) .unwrap_or_else(|_| "unknown".into()); println!("Service state: {}", out.trim()); + match linux_linger_state() { + Ok(Some(enabled)) => println!( + "Linger: {}", + if enabled { + "enabled (keeps service alive without login)" + } else { + "disabled (service starts after user login)" + } + ), + Ok(None) => println!("Linger: unknown"), + Err(e) => println!("Linger: unavailable ({e})"), + } println!("Unit: {}", linux_service_file(config)?.display()); return Ok(()); } @@ -137,6 +167,81 @@ fn status(config: &Config) -> Result<()> { anyhow::bail!("Service management is supported on macOS and Linux only") } +fn maybe_warn_unsupported_linger_mode(linger: crate::ServiceLingerMode) { + if !matches!(linger, crate::ServiceLingerMode::Keep) { + eprintln!( + "⚠️ --linger applies only to Linux user services; ignoring requested mode on this OS." + ); + } +} + +fn current_username() -> Result { + let env_username = std::env::var("USER") + .ok() + .or_else(|| std::env::var("LOGNAME").ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if let Some(username) = env_username { + return Ok(username); + } + + let fallback_username = Command::new("whoami") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + fallback_username.context("Could not resolve current username (USER/LOGNAME)") +} + +fn apply_linux_linger_mode(linger: crate::ServiceLingerMode) -> Result<()> { + match linger { + crate::ServiceLingerMode::Keep => Ok(()), + crate::ServiceLingerMode::On => { + let user = current_username()?; + run_checked(Command::new("loginctl").args(["enable-linger", &user]))?; + println!("✅ Enabled linger for user '{user}'"); + Ok(()) + } + crate::ServiceLingerMode::Off => { + let user = current_username()?; + run_checked(Command::new("loginctl").args(["disable-linger", &user]))?; + println!("✅ Disabled linger for user '{user}'"); + Ok(()) + } + } +} + +fn linux_linger_state() -> Result> { + if !cfg!(target_os = "linux") { + return Ok(None); + } + + let user = current_username()?; + let output = Command::new("loginctl") + .args(["show-user", &user, "-p", "Linger"]) + .output() + .context("Failed to spawn command")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Command failed: {}", stderr.trim()); + } + + let raw = String::from_utf8_lossy(&output.stdout); + let value = raw + .trim() + .split_once('=') + .map_or(raw.trim(), |(_, rhs)| rhs.trim()); + if value.is_empty() { + return Ok(None); + } + + Ok(Some(value.eq_ignore_ascii_case("yes"))) +} + fn uninstall(config: &Config) -> Result<()> { stop(config)?; @@ -353,6 +458,48 @@ fn xml_escape(raw: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn set_env(key: &str, value: &str) { + // SAFETY: tests take ENV_LOCK to serialize process-wide env mutation. + unsafe { std::env::set_var(key, value) }; + } + + fn remove_env(key: &str) { + // SAFETY: tests take ENV_LOCK to serialize process-wide env mutation. + unsafe { std::env::remove_var(key) }; + } + + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var(key).ok(); + set_env(key, value); + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + remove_env(key); + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + set_env(self.key, value); + } else { + remove_env(self.key); + } + } + } #[test] fn xml_escape_escapes_reserved_chars() { @@ -397,6 +544,42 @@ mod tests { assert_eq!(windows_task_name(), "Corvus Daemon"); } + #[test] + fn current_username_prefers_user_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let _user = EnvGuard::set("USER", "testuser"); + let _logname = EnvGuard::set("LOGNAME", "loguser"); + + let result = current_username().unwrap(); + + assert_eq!(result, "testuser"); + } + + #[test] + fn current_username_uses_logname_when_user_missing() { + let _guard = ENV_LOCK.lock().unwrap(); + let _user = EnvGuard::remove("USER"); + let _logname = EnvGuard::set("LOGNAME", "loguser"); + + let result = current_username().unwrap(); + + assert_eq!(result, "loguser"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_helpers_compile_and_are_callable() { + let _ = apply_linux_linger_mode(crate::ServiceLingerMode::Keep); + let _ = linux_linger_state(); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn linux_helpers_compile_on_non_linux() { + let _apply: fn(crate::ServiceLingerMode) -> Result<()> = apply_linux_linger_mode; + let _state: fn() -> Result> = linux_linger_state; + } + #[cfg(target_os = "windows")] #[test] fn run_capture_reads_stdout_windows() { diff --git a/clients/agent-runtime/src/tools/http_request.rs b/clients/agent-runtime/src/tools/http_request.rs index c9511654d..0953ed453 100755 --- a/clients/agent-runtime/src/tools/http_request.rs +++ b/clients/agent-runtime/src/tools/http_request.rs @@ -302,12 +302,12 @@ impl Tool for HttpRequestTool { // Get response headers (redact sensitive ones) let response_headers = response.headers().iter(); let headers_text = response_headers - .map(|(k, _)| { + .map(|(k, v)| { let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); if is_sensitive { format!("{}: ***REDACTED***", k.as_str()) } else { - format!("{}: {:?}", k.as_str(), k.as_str()) + format!("{}: {:?}", k.as_str(), v) } }) .collect::>() @@ -777,7 +777,6 @@ mod tests { .any(|(k, v)| k == "Content-Type" && v == "application/json")); } - #[test] fn parse_headers_rejects_hop_by_hop_headers() { let tool = test_tool(vec!["example.com"]); @@ -807,7 +806,7 @@ mod tests { let tool = test_tool(vec!["example.com"]); let headers = json!({ "X-Test": "value -malicious" + malicious" }); let err = tool.parse_headers(&headers).unwrap_err().to_string(); diff --git a/clients/agent-runtime/src/update/mod.rs b/clients/agent-runtime/src/update/mod.rs new file mode 100644 index 000000000..1afb4aa53 --- /dev/null +++ b/clients/agent-runtime/src/update/mod.rs @@ -0,0 +1,406 @@ +use crate::config::Config; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const VERSION_CHECK_FILE: &str = "version_check.json"; +const VERSION_CHECK_TTL_SECS: u64 = 24 * 60 * 60; +const VERSION_CHECK_TIMEOUT_SECS: u64 = 2; +const UPDATE_CHECK_DISABLE_ENV: &str = "CORVUS_DISABLE_UPDATE_CHECK"; +const INSTALL_SCRIPT_URL: &str = "https://profiletailors.com/install"; +const PACKAGE_NAME: &str = "@dallay/corvus"; +const RELEASE_ENDPOINTS: [&str; 2] = [ + "https://api.github.com/repos/profiletailors/corvus/releases/latest", + "https://api.github.com/repos/dallay/corvus/releases/latest", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct VersionCheckState { + latest_version: String, + checked_at_unix: u64, + update_available: bool, +} + +#[derive(Debug, Deserialize)] +struct LatestReleaseResponse { + tag_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpdateNotice { + current_version: String, + latest_version: String, +} + +pub async fn maybe_print_update_notice(config: &Config) { + if is_update_check_disabled() { + return; + } + + if let Some(notice) = check_for_update(config, env!("CARGO_PKG_VERSION")).await { + println!(); + println!( + "⬆️ Update available: v{} (current v{})", + notice.latest_version, notice.current_version + ); + println!( + " If installed via script/binary: curl -fsSL {} | bash", + INSTALL_SCRIPT_URL + ); + println!( + " If installed via package manager: npm i -g {}@latest (or pnpm/yarn/bun)", + PACKAGE_NAME + ); + } +} + +async fn check_for_update(config: &Config, current_version: &str) -> Option { + let current = normalize_version(current_version)?; + let state_path = version_check_path(&config.workspace_dir); + let cached_state = load_state(&state_path).await.ok().flatten(); + + if let Some(cached) = cached_state.as_ref().filter(|state| !is_stale(state)) { + return notice_from_state(current.clone(), cached); + } + + let fetched = fetch_latest_release_version().await.ok(); + + if let Some(latest_version) = fetched { + let update_available = + compare_semverish(&latest_version, ¤t).is_some_and(|ordering| ordering.is_gt()); + let state = VersionCheckState { + latest_version, + checked_at_unix: now_unix_secs(), + update_available, + }; + + let _ = save_state(&state_path, &state).await; + return notice_from_state(current, &state); + } + + cached_state + .as_ref() + .and_then(|state| notice_from_state(current, state)) +} + +fn notice_from_state(current_version: String, state: &VersionCheckState) -> Option { + if !state.update_available { + return None; + } + + if compare_semverish(&state.latest_version, ¤t_version) + .is_some_and(|ordering| ordering.is_gt()) + { + Some(UpdateNotice { + current_version, + latest_version: state.latest_version.clone(), + }) + } else { + None + } +} + +fn is_update_check_disabled() -> bool { + std::env::var(UPDATE_CHECK_DISABLE_ENV) + .ok() + .is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }) +} + +fn version_check_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("state").join(VERSION_CHECK_FILE) +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +fn is_stale(state: &VersionCheckState) -> bool { + now_unix_secs().saturating_sub(state.checked_at_unix) > VERSION_CHECK_TTL_SECS +} + +async fn load_state(path: &Path) -> Result> { + if !tokio::fs::try_exists(path) + .await + .with_context(|| format!("failed to check version check state at {}", path.display()))? + { + return Ok(None); + } + + let raw = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("failed to read version check state at {}", path.display()))?; + let state = serde_json::from_str::(&raw) + .context("failed to parse version check state")?; + Ok(Some(state)) +} + +async fn save_state(path: &Path, state: &VersionCheckState) -> Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.with_context(|| { + format!( + "failed to create version check state directory {}", + parent.display() + ) + })?; + } + + let body = serde_json::to_vec_pretty(state).context("failed to serialize version state")?; + tokio::fs::write(path, body) + .await + .with_context(|| format!("failed to write version check state at {}", path.display())) +} + +async fn fetch_latest_release_version() -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(VERSION_CHECK_TIMEOUT_SECS)) + .build() + .context("failed to build update-check client")?; + + for endpoint in RELEASE_ENDPOINTS { + let response = client + .get(endpoint) + .header(reqwest::header::USER_AGENT, "corvus-update-check") + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await; + + let Ok(response) = response else { + continue; + }; + + let Ok(response) = response.error_for_status() else { + continue; + }; + + let payload: LatestReleaseResponse = response + .json() + .await + .context("failed to parse release metadata")?; + + if let Some(normalized) = normalize_version(&payload.tag_name) { + return Ok(normalized); + } + } + + anyhow::bail!("failed to resolve latest release version from release endpoints") +} + +fn normalize_version(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed) + .to_string(); + + parse_semverish(&normalized).map(|_| normalized) +} + +fn compare_semverish(left: &str, right: &str) -> Option { + let left_parsed = parse_semverish(left)?; + let right_parsed = parse_semverish(right)?; + + let core_ordering = left_parsed + .0 + .cmp(&right_parsed.0) + .then(left_parsed.1.cmp(&right_parsed.1)) + .then(left_parsed.2.cmp(&right_parsed.2)); + if !core_ordering.is_eq() { + return Some(core_ordering); + } + + Some(compare_prerelease( + left_parsed.3.as_deref(), + right_parsed.3.as_deref(), + )) +} + +fn compare_prerelease(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering { + match (left, right) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (Some(left), Some(right)) => { + let left_parts: Vec<&str> = left.split('.').collect(); + let right_parts: Vec<&str> = right.split('.').collect(); + + for (l, r) in left_parts.iter().zip(right_parts.iter()) { + let left_numeric = l.parse::(); + let right_numeric = r.parse::(); + + let ordering = match (left_numeric, right_numeric) { + (Ok(a), Ok(b)) => a.cmp(&b), + (Ok(_), Err(_)) => std::cmp::Ordering::Less, + (Err(_), Ok(_)) => std::cmp::Ordering::Greater, + (Err(_), Err(_)) => l.cmp(r), + }; + + if !ordering.is_eq() { + return ordering; + } + } + + left_parts.len().cmp(&right_parts.len()) + } + } +} + +fn parse_semverish(version: &str) -> Option<(u64, u64, u64, Option)> { + let version = version.trim(); + if version.is_empty() { + return None; + } + + let without_build = version.split_once('+').map_or(version, |(core, _)| core); + let (core, prerelease_raw) = without_build + .split_once('-') + .map_or((without_build, None), |(core, prerelease)| { + (core, Some(prerelease.trim())) + }); + + let core = core.trim(); + if core.is_empty() { + return None; + } + + let mut parts = core.split('.'); + + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + if parts.next().is_some() { + return None; + } + + let prerelease = prerelease_raw + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + Some((major, minor, patch, prerelease)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_version_accepts_with_or_without_v_prefix() { + assert_eq!(normalize_version("v0.1.7"), Some("0.1.7".to_string())); + assert_eq!(normalize_version("0.1.7"), Some("0.1.7".to_string())); + assert_eq!(normalize_version("V1.2.3"), Some("1.2.3".to_string())); + } + + #[test] + fn normalize_version_rejects_invalid_values() { + assert_eq!(normalize_version("latest"), None); + assert_eq!(normalize_version("v1"), None); + assert_eq!(normalize_version(""), None); + } + + #[test] + fn compare_semverish_orders_versions() { + assert_eq!( + compare_semverish("0.1.8", "0.1.7"), + Some(std::cmp::Ordering::Greater) + ); + assert_eq!( + compare_semverish("0.1.7", "0.1.7"), + Some(std::cmp::Ordering::Equal) + ); + assert_eq!( + compare_semverish("0.1.6", "0.1.7"), + Some(std::cmp::Ordering::Less) + ); + } + + #[test] + fn parse_semverish_accepts_pre_release_patch_suffix() { + assert_eq!( + parse_semverish("1.2.3-beta.1"), + Some((1, 2, 3, Some("beta.1".to_string()))) + ); + } + + #[test] + fn compare_semverish_treats_prerelease_as_lower_precedence() { + assert_eq!( + compare_semverish("1.0.0", "1.0.0-beta.1"), + Some(std::cmp::Ordering::Greater) + ); + assert_eq!( + compare_semverish("1.0.0-beta.1", "1.0.0"), + Some(std::cmp::Ordering::Less) + ); + } + + #[test] + fn stale_cache_detection_works() { + let now = now_unix_secs(); + let fresh = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now.saturating_sub(30), + update_available: true, + }; + let stale = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now.saturating_sub(VERSION_CHECK_TTL_SECS + 10), + update_available: true, + }; + + assert!(!is_stale(&fresh)); + assert!(is_stale(&stale)); + } + + #[test] + fn notice_requires_newer_latest_version() { + let update = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now_unix_secs(), + update_available: true, + }; + let no_update_same = VersionCheckState { + latest_version: "0.1.7".into(), + checked_at_unix: now_unix_secs(), + update_available: true, + }; + let no_update_flag = VersionCheckState { + latest_version: "0.1.8".into(), + checked_at_unix: now_unix_secs(), + update_available: false, + }; + + assert!(notice_from_state("0.1.7".into(), &update).is_some()); + assert!(notice_from_state("0.1.7".into(), &no_update_same).is_none()); + assert!(notice_from_state("0.1.7".into(), &no_update_flag).is_none()); + } + + #[tokio::test] + async fn save_and_load_state_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state").join("version_check.json"); + let state = VersionCheckState { + latest_version: "0.1.9".into(), + checked_at_unix: 123, + update_available: true, + }; + + save_state(&path, &state).await.unwrap(); + let loaded = load_state(&path).await.unwrap().unwrap(); + + assert_eq!(loaded.latest_version, "0.1.9"); + assert_eq!(loaded.checked_at_unix, 123); + assert!(loaded.update_available); + } +}