use std::time::Duration; use anyhow::Result; use serde::Deserialize; use tokio::fs; use kigi_shell::util::kigi_home::kigi_home; const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30); /// Release channel: this repo's GitHub Releases (PRD F8). The API base is /// resolved through [`kigi_env::update_base_url`] — production default /// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`, /// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests. pub(crate) fn update_base_url() -> String { kigi_env::update_base_url() } /// Minimal configuration the update system needs from the environment. /// /// Constructed once at startup and threaded through the update call chain. #[derive(Debug, Clone)] pub struct UpdateConfig { /// Subscription coding-API base URL ([`kigi_env::coding_api_base_url`]). pub proxy_base_url: String, /// Auth scope key for `~/.kigi/auth.json`. pub auth_scope: String, /// Enterprise deployment key (KIGI_DEPLOYMENT_KEY). pub deployment_key: Option, /// Optional extra auth material forwarded with requests when present. pub alpha_test_key: Option, /// Release channel: "stable" or "alpha". Loaded from config. pub channel: String, } impl UpdateConfig { pub fn from_environment() -> Self { Self { proxy_base_url: kigi_env::coding_api_base_url(), auth_scope: kigi_shell::auth::KimiCodeConfig::default().auth_scope(), deployment_key: None, alpha_test_key: None, channel: "stable".to_string(), } } } // ───────────────────────────────────────────────────────────────────────────── // GitHub Releases API wire shape // ───────────────────────────────────────────────────────────────────────────── /// One downloadable asset attached to a GitHub release. /// /// Wire shape per the GitHub REST API /// (): /// `GET /repos/{owner}/{repo}/releases/latest` → /// `{"tag_name":"v0.1.0","assets":[{"name":"...","browser_download_url":"..."}]}`. /// Unknown fields are ignored. #[derive(Debug, Clone, Deserialize)] pub struct ReleaseAsset { pub name: String, pub browser_download_url: String, } /// A GitHub release, reduced to the fields the updater consumes. #[derive(Debug, Clone, Deserialize)] pub struct Release { /// Git tag, e.g. `v0.1.0`. [`Release::version`] strips the `v` prefix. pub tag_name: String, #[serde(default)] pub draft: bool, #[serde(default)] pub prerelease: bool, #[serde(default)] pub assets: Vec, } impl Release { /// Semver version from `tag_name` (`v0.1.0` → `0.1.0`). Errors on a tag /// that is not a `v`-prefixed (or bare) semver string. pub fn version(&self) -> Result { let v = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name); semver::Version::parse(v) .map_err(|e| anyhow::anyhow!("release tag '{}' is not semver: {e}", self.tag_name))?; Ok(v.to_string()) } /// Asset with exactly `name`, or an error listing what the release has. pub fn asset(&self, name: &str) -> Result<&ReleaseAsset> { self.assets.iter().find(|a| a.name == name).ok_or_else(|| { anyhow::anyhow!( "release {} has no asset named '{}' (available: {})", self.tag_name, name, self.assets .iter() .map(|a| a.name.as_str()) .collect::>() .join(", ") ) }) } } /// Shared HTTP client for GitHub API metadata requests. GitHub rejects /// requests without a `User-Agent`, so one is always set. fn github_api_client(timeout: Duration) -> Result { Ok(reqwest::Client::builder() .user_agent(concat!("kigi/", env!("CARGO_PKG_VERSION"))) .timeout(timeout) .build()?) } /// GET `url` and decode the JSON body as `T`, retrying transient failures /// (network errors, HTTP 5xx) up to 3 times with 1s/2s/4s backoff. /// Non-5xx HTTP errors (404 missing release, 403 rate limit) fail fast. async fn fetch_github_json(url: &str) -> Result { let client = github_api_client(Duration::from_secs(15))?; let max_retries: u32 = 3; let mut last_err: Option = None; for attempt in 0..=max_retries { if attempt > 0 { tokio::time::sleep(Duration::from_secs(1 << (attempt - 1))).await; } let resp = match client .get(url) .header("Accept", "application/vnd.github+json") .send() .await { Ok(r) => r, Err(e) => { last_err = Some(anyhow::anyhow!( "GitHub API request failed for {url}: {e:#}" )); continue; } }; let status = resp.status(); if status.is_server_error() { last_err = Some(anyhow::anyhow!( "GitHub API returned HTTP {status} for {url}" )); continue; } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); anyhow::bail!( "GitHub API returned HTTP {} for {}: {}", status, url, body.chars().take(200).collect::().trim() ); } let body = match resp.text().await { Ok(b) => b, Err(e) => { last_err = Some(anyhow::anyhow!( "GitHub API body read failed for {url}: {e:#}" )); continue; } }; return serde_json::from_str(&body) .map_err(|e| anyhow::anyhow!("GitHub API returned unexpected JSON for {url}: {e}")); } Err(last_err.expect("loop ran at least once")) } /// Latest release for `channel` from a GitHub-Releases-shaped API at `base`. /// /// - `stable` / `enterprise`: `GET {base}/latest` — GitHub's "latest" already /// excludes drafts and pre-releases. /// - `alpha`: `GET {base}?per_page=30` (newest first) and take the semver-max /// non-draft entry. The list includes both pre-releases and stable /// releases, so this preserves the max(alpha, stable) channel semantics — /// alpha users are never stuck behind a newer stable. pub async fn fetch_latest_release_from_base(channel: &str, base: &str) -> Result { let base = base.trim_end_matches('/'); if channel == "alpha" { let releases: Vec = fetch_github_json(&format!("{base}?per_page=30")).await?; return releases .into_iter() .filter(|r| !r.draft) .filter_map(|r| { let v = semver::Version::parse(r.tag_name.strip_prefix('v').unwrap_or(&r.tag_name)) .ok()?; Some((v, r)) }) .max_by(|a, b| a.0.cmp(&b.0)) .map(|(_, r)| r) .ok_or_else(|| anyhow::anyhow!("no releases with semver tags found at {base}")); } fetch_github_json(&format!("{base}/latest")).await } /// Release for an exact version (`GET {base}/tags/v{version}`), used by /// pinned installs (`kigi update --version X`) and rollbacks. pub async fn fetch_release_for_version_from_base(version: &str, base: &str) -> Result { let base = base.trim_end_matches('/'); fetch_github_json(&format!("{base}/tags/v{version}")).await } /// Latest release for `channel` from the production base URL /// ([`kigi_env::update_base_url`]). pub(crate) async fn fetch_latest_release(channel: &str) -> Result { fetch_latest_release_from_base(channel, &update_base_url()).await } /// Fetch the latest version for the configured channel without writing the /// version cache. Use this when the caller needs to control when the cache is /// written (e.g. auto-update should only cache after a successful install or /// when no update is needed). pub async fn fetch_latest_version(config: &UpdateConfig) -> Result { fetch_latest_release(&config.channel).await?.version() } /// Fetch the latest version for the configured channel and cache it. pub async fn get_latest_version(config: &UpdateConfig) -> Result { let version = fetch_latest_version(config).await?; let stable_ptr = try_fetch_stable_version().await; write_version_cache(&version, stable_ptr.as_deref()).await; Ok(version) } /// Fetch the latest stable version for caching alongside the version, so /// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O. /// /// Best-effort and capped at 500 ms: the label is cosmetic, never required /// for correctness. On slow or unreachable networks the timeout fires and we /// return `None`; the label populates on the next successful TTL check /// (~30 min). This keeps startup and post-install paths fast. pub(crate) async fn try_fetch_stable_version() -> Option { tokio::time::timeout(Duration::from_millis(500), async { fetch_latest_release("stable").await.ok()?.version().ok() }) .await .unwrap_or(None) } // ───────────────────────────────────────────────────────────────────────────── // Version cache (~/.kigi/version.json) // ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, serde::Serialize, Deserialize)] struct VersionCache { version: String, #[serde(default)] stable_version: Option, checked_at: String, } impl VersionCache { fn is_fresh(&self, now: time::OffsetDateTime, ttl: Duration) -> bool { if let Ok(dt) = time::OffsetDateTime::parse( &self.checked_at, &time::format_description::well_known::Rfc3339, ) { // Clock-skew guard: future timestamps are never fresh. if dt > now { return false; } now - dt < ttl } else { false } } fn new(version: String, stable_version: Option, now: time::OffsetDateTime) -> Self { let checked_at = now .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| now.to_string()); Self { version, stable_version, checked_at, } } } /// Write the version cache to disk, recording that `version` was seen at the /// current time. Call after confirming the version is current (no update /// needed) or after a successful install. /// /// `stable_version` records the current stable release so that /// `channel_label()` can derive `[alpha]` vs `[stable]` without network I/O. pub async fn write_version_cache(version: &str, stable_version: Option<&str>) { let version_path = kigi_home().join("version.json"); let now = time::OffsetDateTime::now_utc(); let json = VersionCache::new( version.to_string(), stable_version.map(|s| s.to_string()), now, ); if let Some(dir) = version_path.parent() && let Err(e) = fs::create_dir_all(dir).await { tracing::warn!("failed to create version cache directory: {}", e); return; } let tmp = version_path.with_extension("json.tmp"); let data = match serde_json::to_vec_pretty(&json) { Ok(d) => d, Err(e) => { tracing::warn!("failed to serialize version cache: {}", e); return; } }; if let Err(e) = fs::write(&tmp, data).await { tracing::warn!("failed to write version cache tmp file: {}", e); return; } if let Err(e) = fs::rename(&tmp, &version_path).await { tracing::warn!("failed to rename version cache file: {}", e); } } /// True if `version.json` exists and is within TTL. pub async fn is_version_cache_fresh() -> bool { let version_path = kigi_home().join("version.json"); let now = time::OffsetDateTime::now_utc(); if let Ok(version_str) = fs::read_to_string(&version_path).await && let Ok(version) = serde_json::from_str::(&version_str) && version.is_fresh(now, TTL_SECONDS_BEFORE_AUTO_UPDATE) { return true; } false } pub use kigi_version::installed as get_installed_kigi_version; /// Version of the managed kigi binary currently on disk, read from the /// `~/.kigi/bin/kigi` symlink target (`../downloads/kigi--`) /// without exec'ing anything. /// /// Concurrent updaters (TUI background download, leader hourly checker, /// explicit `kigi update`) decide staleness from this instead of their own /// compiled-in version, so a binary another process already installed is /// never downloaded a second time. /// /// Returns `None` when there is no parseable managed symlink (Windows /// copy-based installs, dev builds) or when the symlink is DANGLING — a /// link whose target binary was deleted (e.g. manual `~/.kigi/downloads` /// cleanup) must not report an installed version, or every updater would /// claim "already up to date" forever while no runnable binary exists. pub fn installed_on_disk_version() -> Option { #[cfg(unix)] { let app = kigi_shell::util::kigi_home::kigi_application(); let target = std::fs::read_link(&app).ok()?; // metadata() follows the symlink: Err means the target is gone // (dangling link) and the version it names is not actually on disk. std::fs::metadata(&app).ok()?; version_from_versioned_binary_name(target.file_name()?.to_str()?, "kigi") } #[cfg(not(unix))] { None } } /// Extract the `` portion of a versioned binary file name. /// /// Handles the managed layout (`kigi-0.1.0-macos-aarch64`, including /// pre-releases: `kigi-0.1.0-alpha.1-linux-x86_64` → `0.1.0-alpha.1`) and a /// bare versioned name without a platform suffix (`kigi-0.1.0`): everything /// between the `{bin_prefix}-` prefix and the first platform-OS component is /// the version, validated as semver so unknown layouts (`kigi-latest`) /// return `None` instead of garbage. /// /// Shared by the disk-version probe above and `cleanup_old_downloads` in /// `auto_update` — keep it the single place that understands this naming. pub(crate) fn version_from_versioned_binary_name(name: &str, bin_prefix: &str) -> Option { const PLATFORM_OS: &[&str] = &["macos", "linux", "darwin", "windows"]; // Release archives (`kigi--.tar.gz|.zip`) are downloads, not // binaries — and their triple would otherwise parse as a semver // pre-release (`0.1.0-aarch64-apple-darwin.tar.gz` is valid semver). if name.ends_with(".tar.gz") || name.ends_with(".zip") { return None; } let suffix = name.strip_prefix(bin_prefix)?.strip_prefix('-')?; let parts: Vec<&str> = suffix.split('-').collect(); let platform_start = parts .iter() .position(|p| PLATFORM_OS.contains(p)) .unwrap_or(parts.len()); let ver_str = parts[..platform_start].join("-"); semver::Version::parse(&ver_str).ok()?; Some(ver_str) } /// Read the cached stable version from `~/.kigi/version.json` (sync, for display). /// /// Returns `None` if the file doesn't exist, can't be parsed, or has no /// `stable_version` field (e.g. written by an older binary). pub fn cached_stable_version() -> Option { let version_path = kigi_home().join("version.json"); let content = std::fs::read_to_string(&version_path).ok()?; let gv: VersionCache = serde_json::from_str(&content).ok()?; gv.stable_version } /// Pure comparison: derive the channel name from current vs stable pointer. /// /// Returns `Some("alpha")` when `current > stable`, `Some("stable")` when /// `current <= stable`, or `None` when either version fails to parse. fn derive_channel<'a>(current: &str, stable: &str) -> Option<&'a str> { let current_v = semver::Version::parse(current).ok()?; let stable_v = semver::Version::parse(stable).ok()?; if current_v > stable_v { Some("alpha") } else { Some("stable") } } /// Machine-readable channel name derived from the cached stable pointer. /// /// Returns `Some("alpha")` when the current version is ahead of the cached /// stable pointer, `Some("stable")` when at or behind, or `None` when no /// cached pointer is available (first launch, old cache format, parse error). /// /// The result is computed once and cached for the process lifetime. pub fn channel_name() -> Option<&'static str> { use std::sync::OnceLock; static NAME: OnceLock> = OnceLock::new(); *NAME.get_or_init(|| { let stable = cached_stable_version()?; derive_channel(kigi_version::VERSION, &stable) }) } /// Channel label derived from the cached stable pointer. /// /// Compares the compiled-in `VERSION` against the stable pointer stored in /// `~/.kigi/version.json` (written by the auto-updater): /// - `" [alpha]"` when the current version is ahead of stable, /// - `" [stable]"` when at or behind stable, /// - `""` when no cached pointer is available (first launch, old cache format). /// /// The result is computed once and cached for the process lifetime. pub fn channel_label() -> &'static str { use std::sync::OnceLock; static LABEL: OnceLock<&'static str> = OnceLock::new(); LABEL.get_or_init(|| { let stable = match cached_stable_version() { Some(s) => s, None => return "", }; match derive_channel(kigi_version::VERSION, &stable) { Some("alpha") => " [alpha]", Some(_) => " [stable]", None => "", } }) } #[cfg(test)] mod tests { use super::*; /// Verifies that a future `checked_at` timestamp (e.g. from clock skew or /// NTP time-warp) is never considered fresh. Without the clock-skew guard /// this would return true indefinitely, silently disabling auto-update. #[test] fn test_is_fresh_rejects_future_timestamp() { let now = time::OffsetDateTime::now_utc(); let future = now + Duration::from_secs(600); let v = VersionCache::new("0.1.200".to_string(), None, future); assert!( !v.is_fresh(now, Duration::from_secs(30)), "Future timestamp must not be considered fresh (clock-skew guard)." ); } /// Disk-version probe: parsing the version out of the managed install's /// symlink-target file name (`kigi--`). #[test] fn test_version_from_versioned_binary_name() { let cases: &[(&str, Option<&str>)] = &[ ("kigi-0.2.46-darwin-arm64", Some("0.2.46")), ("kigi-0.1.220-linux-x86_64", Some("0.1.220")), ("kigi-0.2.5-windows-x86_64.exe", Some("0.2.5")), // Pre-releases must round-trip whole — truncating to "0.1.220" // would make an alpha install masquerade as the release and // mask alpha → stable updates. ("kigi-0.1.220-alpha.4-linux-x86_64", Some("0.1.220-alpha.4")), ("kigi-0.1.220-alpha.4", Some("0.1.220-alpha.4")), // no platform suffix ("kigi-garbage-darwin-arm64", None), // unparseable version ("kigi-0.2.46", Some("0.2.46")), // no platform suffix ("other-0.2.46-darwin-arm64", None), // wrong prefix ("kigi-latest", None), // symlink alias, not a version ("kigi", None), // bare name ("", None), // Release archives must never parse as versioned binaries, or // cleanup would treat them as installable versions. ("kigi-0.1.0-aarch64-apple-darwin.tar.gz", None), ("kigi-0.1.0-x86_64-pc-windows-msvc.zip", None), ]; for (name, expected) in cases { assert_eq!( version_from_versioned_binary_name(name, "kigi").as_deref(), *expected, "version_from_versioned_binary_name({name:?})" ); } // bin_prefix discrimination: a differently-prefixed binary parses // under its own prefix but not under "kigi". assert_eq!( version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi-pager") .as_deref(), Some("0.1.5") ); assert_eq!( version_from_versioned_binary_name("kigi-pager-0.1.5-darwin-arm64", "kigi"), None, "\"pager\" is not a version" ); } // ────────────────────────────────────────────────────────────────────── // GitHub release JSON — wire-shape invariants // // Fixtures mirror the real GitHub REST API response for // GET /repos/{owner}/{repo}/releases/latest: // https://docs.github.com/en/rest/releases/releases#get-the-latest-release // ────────────────────────────────────────────────────────────────────── #[test] fn test_release_json_parses_github_wire_shape() { let json = r#"{ "url": "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases/1", "tag_name": "v0.1.0", "name": "Kigi 0.1.0", "draft": false, "prerelease": false, "assets": [ { "name": "kigi-0.1.0-aarch64-apple-darwin.tar.gz", "browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz", "size": 123, "content_type": "application/gzip" }, { "name": "SHA256SUMS", "browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS" } ] }"#; let r: Release = serde_json::from_str(json).unwrap(); assert_eq!(r.tag_name, "v0.1.0"); assert_eq!(r.version().unwrap(), "0.1.0"); assert!(!r.draft); assert!(!r.prerelease); assert_eq!(r.assets.len(), 2); let asset = r.asset("kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap(); assert_eq!( asset.browser_download_url, "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz" ); assert_eq!( r.asset("SHA256SUMS").unwrap().name, "SHA256SUMS", "checksum asset resolved by exact name" ); } #[test] fn test_release_version_accepts_bare_and_v_prefixed_tags() { let mk = |tag: &str| Release { tag_name: tag.to_string(), draft: false, prerelease: false, assets: vec![], }; assert_eq!(mk("v0.1.0").version().unwrap(), "0.1.0"); assert_eq!(mk("0.1.0").version().unwrap(), "0.1.0"); assert_eq!(mk("v0.2.0-alpha.3").version().unwrap(), "0.2.0-alpha.3"); assert!(mk("release-1").version().is_err()); assert!(mk("").version().is_err()); } #[test] fn test_release_missing_asset_error_lists_available() { let r = Release { tag_name: "v0.1.0".to_string(), draft: false, prerelease: false, assets: vec![ReleaseAsset { name: "SHA256SUMS".to_string(), browser_download_url: "https://example.test/SHA256SUMS".to_string(), }], }; let err = r .asset("kigi-0.1.0-x86_64-unknown-linux-gnu.tar.gz") .unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("no asset named"), "msg: {msg}"); assert!(msg.contains("SHA256SUMS"), "must list available: {msg}"); } #[test] fn test_release_defaults_for_absent_optional_fields() { // Minimal object: only tag_name. draft/prerelease default false, // assets default empty (serde(default)). let r: Release = serde_json::from_str(r#"{"tag_name":"v0.1.0"}"#).unwrap(); assert!(!r.draft && !r.prerelease && r.assets.is_empty()); // tag_name is required. assert!(serde_json::from_str::(r#"{"assets":[]}"#).is_err()); } // ────────────────────────────────────────────────────────────────────── // derive_channel — invariant matrix // // Tests the pure comparison logic that determines [alpha] vs [stable]. // Covers current 0.1.X-alpha.N, future 0.2.X, edge cases, and errors. // ────────────────────────────────────────────────────────────────────── #[test] fn test_derive_channel_matrix() { // (current, stable_pointer, expected_channel) let cases: &[(&str, &str, Option<&str>)] = &[ // ── Current 0.1.X workflow ── ("0.1.220-alpha.2", "0.1.219", Some("alpha")), // alpha ahead of stable ("0.1.219", "0.1.219", Some("stable")), // stable user on latest ("0.1.218", "0.1.219", Some("stable")), // stable user behind latest ("0.1.220-alpha.2", "0.1.220-alpha.2", Some("stable")), // pointer matches exactly ("0.1.220-alpha.2", "0.1.220", Some("stable")), // semver: release > pre-release // ── Future 0.2.X workflow ── ("0.2.5", "0.2.3", Some("alpha")), // alpha ahead of stable ("0.2.5", "0.2.5", Some("stable")), // promoted to stable ("0.2.3", "0.2.5", Some("stable")), // behind stable ("0.2.0", "0.2.0", Some("stable")), // first release, both 0.2.0 // ── Cross-regime upgrade ── ("0.2.0", "0.1.219", Some("alpha")), // new regime ahead of old stable ("0.1.220-alpha.2", "0.2.0", Some("stable")), // old pre-release < new stable // ── Error cases ── ("garbage", "0.1.219", None), // unparseable current ("0.1.219", "garbage", None), // unparseable stable ("", "0.1.219", None), // empty current ("0.1.219", "", None), // empty stable ]; for (current, stable, expected) in cases { let result = derive_channel(current, stable); assert_eq!( result, *expected, "derive_channel({:?}, {:?}) = {:?}, expected {:?}", current, stable, result, expected, ); } } // ────────────────────────────────────────────────────────────────────── // VersionCache JSON shape — backward compatibility invariants // ────────────────────────────────────────────────────────────────────── #[test] fn test_version_json_backward_compat() { // Old format (no stable_version) must parse — serde(default) fills None. let old = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z"}"#; let v: VersionCache = serde_json::from_str(old).unwrap(); assert_eq!(v.version, "0.1.180"); assert!(v.stable_version.is_none()); // New format with stable_version round-trips correctly. let now = time::OffsetDateTime::now_utc(); let new = VersionCache::new("0.2.5".to_string(), Some("0.2.3".to_string()), now); let json = serde_json::to_string(&new).unwrap(); let parsed: VersionCache = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.version, "0.2.5"); assert_eq!(parsed.stable_version.as_deref(), Some("0.2.3")); // checked_at must be valid RFC3339. assert!( time::OffsetDateTime::parse( &parsed.checked_at, &time::format_description::well_known::Rfc3339, ) .is_ok() ); // Unknown fields are ignored (forward-compat). let future = r#"{"version":"0.1.180","checked_at":"2026-04-22T10:30:00Z","future":"ok"}"#; assert!(serde_json::from_str::(future).is_ok()); // Missing required field (checked_at) is rejected. let missing = r#"{"version":"0.1.180"}"#; assert!(serde_json::from_str::(missing).is_err()); } // ────────────────────────────────────────────────────────────────────── // is_fresh — TTL boundary invariants // ────────────────────────────────────────────────────────────────────── #[test] fn test_is_fresh_ttl_boundaries() { let now = time::OffsetDateTime::now_utc(); let v = VersionCache::new("0.1.200".to_string(), None, now); // Within TTL → fresh assert!(v.is_fresh(now, Duration::from_secs(60))); assert!(v.is_fresh(now + Duration::from_secs(29), Duration::from_secs(30))); // At TTL boundary → NOT fresh (strict <) assert!(!v.is_fresh(now + Duration::from_secs(30), Duration::from_secs(30))); // Past TTL → not fresh assert!(!v.is_fresh(now + Duration::from_secs(31), Duration::from_secs(30))); // Zero TTL → never fresh assert!(!v.is_fresh(now, Duration::ZERO)); // Malformed timestamp → not fresh let bad = VersionCache { version: "0.1.200".to_string(), stable_version: None, checked_at: "not-rfc3339".to_string(), }; assert!(!bad.is_fresh(now, Duration::from_secs(60))); } // ────────────────────────────────────────────────────────────────────── // UpdateConfig defaults // ────────────────────────────────────────────────────────────────────── #[test] fn test_update_config_default_channel_is_stable() { let cfg = UpdateConfig::from_environment(); assert_eq!(cfg.channel, "stable"); } }