Files
dev/code-rs/tui/src/updates.rs
T

907 lines
30 KiB
Rust

use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::sync::atomic::{AtomicU8, Ordering};
use std::fs;
use std::io::ErrorKind;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use code_core::config::resolve_code_path_for_read;
use code_core::config::Config;
use code_core::default_client::create_client;
use once_cell::sync::Lazy;
use tokio::process::Command;
use tokio::sync::Mutex as AsyncMutex;
use tokio::task;
use tracing::{info, warn};
#[cfg(test)]
use futures::future::BoxFuture;
#[cfg(test)]
use std::future::Future;
#[cfg(test)]
use std::sync::Arc;
const FORCE_UPGRADE_UNSET: u8 = 0;
const FORCE_UPGRADE_FALSE: u8 = 1;
const FORCE_UPGRADE_TRUE: u8 = 2;
static FORCE_UPGRADE_PREVIEW: AtomicU8 = AtomicU8::new(FORCE_UPGRADE_UNSET);
fn force_upgrade_preview_enabled() -> bool {
match FORCE_UPGRADE_PREVIEW.load(Ordering::Relaxed) {
FORCE_UPGRADE_TRUE => true,
FORCE_UPGRADE_FALSE => false,
_ => {
let computed = std::env::var("SHOW_UPGRADE")
.map(|value| {
let normalized = value.trim().to_ascii_lowercase();
matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
})
.unwrap_or(false);
FORCE_UPGRADE_PREVIEW.store(
if computed {
FORCE_UPGRADE_TRUE
} else {
FORCE_UPGRADE_FALSE
},
Ordering::Relaxed,
);
computed
}
}
}
pub fn upgrade_ui_enabled() -> bool {
!cfg!(debug_assertions) || force_upgrade_preview_enabled()
}
pub fn auto_upgrade_runtime_enabled() -> bool {
!cfg!(debug_assertions)
}
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
let read_path = resolve_code_path_for_read(&config.code_home, Path::new(VERSION_FILENAME));
let originator = config.responses_originator_header.clone();
let cached_info = match read_version_info(&read_path) {
Ok(info) => info,
Err(err) => {
warn!(
error = %err,
path = %read_path.display(),
"failed to read cached version info"
);
None
}
};
let should_refresh = cached_info
.as_ref()
.map(|info| !is_cache_fresh(info))
.unwrap_or(true);
if should_refresh {
tokio::spawn(async move {
check_for_update(&version_file, &originator)
.await
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
});
}
cached_info.and_then(|info| {
let current_version = code_version::version();
if is_newer(&info.latest_version, current_version).unwrap_or(false) {
Some(info.latest_version)
} else {
None
}
})
}
#[derive(Debug, Clone)]
pub struct UpdateCheckInfo {
pub latest_version: Option<String>,
}
pub async fn check_for_updates_now(config: &Config) -> anyhow::Result<UpdateCheckInfo> {
let version_file = version_filepath(config);
let originator = config.responses_originator_header.clone();
let info = check_for_update(&version_file, &originator).await?;
let current_version = code_version::version().to_string();
let latest_version = if is_newer(&info.latest_version, &current_version).unwrap_or(false) {
Some(info.latest_version)
} else {
None
};
Ok(UpdateCheckInfo {
latest_version,
})
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct VersionInfo {
latest_version: String,
// ISO-8601 timestamp (RFC3339)
last_checked_at: DateTime<Utc>,
#[serde(default)]
release_repo: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
struct ReleaseInfo {
tag_name: String,
}
const VERSION_FILENAME: &str = "version.json";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/just-every/code/releases/latest";
const CURRENT_RELEASE_REPO: &str = "just-every/code";
const LEGACY_RELEASE_REPO: &str = "openai/codex";
pub const CODE_RELEASE_URL: &str = "https://github.com/just-every/code/releases/latest";
const CACHE_TTL_HOURS: i64 = 20;
const MAX_CLOCK_SKEW_MINUTES: i64 = 5;
static REFRESH_LOCK: Lazy<AsyncMutex<()>> = Lazy::new(|| AsyncMutex::new(()));
#[cfg(test)]
type FetchOverrideFn = Arc<dyn Fn(&str) -> BoxFuture<'static, anyhow::Result<VersionInfo>> + Send + Sync>;
#[cfg(test)]
static FETCH_OVERRIDE: Lazy<std::sync::Mutex<Option<FetchOverrideFn>>> =
Lazy::new(|| std::sync::Mutex::new(None));
#[cfg(test)]
static FETCH_OVERRIDE_TEST_LOCK: Lazy<std::sync::Mutex<()>> =
Lazy::new(|| std::sync::Mutex::new(()));
const AUTO_UPGRADE_LOCK_FILE: &str = "auto-upgrade.lock";
const AUTO_UPGRADE_LOCK_TTL: Duration = Duration::from_secs(900); // 15 minutes
#[derive(Debug, Clone)]
pub enum UpgradeResolution {
Command { command: Vec<String>, display: String },
Manual { instructions: String },
}
fn version_filepath(config: &Config) -> PathBuf {
config.code_home.join(VERSION_FILENAME)
}
pub fn resolve_upgrade_resolution() -> UpgradeResolution {
if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() {
return UpgradeResolution::Command {
command: vec![
"npm".to_string(),
"install".to_string(),
"-g".to_string(),
"@just-every/code@latest".to_string(),
],
display: "npm install -g @just-every/code@latest".to_string(),
};
}
#[cfg(target_os = "macos")]
{
if let Ok(exe_path) = std::env::current_exe() {
if exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local") {
return UpgradeResolution::Command {
command: vec![
"brew".to_string(),
"upgrade".to_string(),
"code".to_string(),
],
display: "brew upgrade code".to_string(),
};
}
}
}
UpgradeResolution::Manual {
instructions: format!(
"Download the latest release from {CODE_RELEASE_URL} and replace the installed binary."
),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoUpgradeOutcome {
pub installed_version: Option<String>,
pub user_notice: Option<String>,
}
pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<AutoUpgradeOutcome> {
if !config.auto_upgrade_enabled {
return Ok(AutoUpgradeOutcome::default());
}
let resolution = resolve_upgrade_resolution();
let (command, command_display) = match resolution {
UpgradeResolution::Command {
command,
display: command_display,
} if !command.is_empty() => (command, command_display),
_ => {
info!("auto-upgrade enabled but no managed installer detected; skipping");
return Ok(AutoUpgradeOutcome::default());
}
};
let info = match check_for_updates_now(config).await {
Ok(info) => info,
Err(err) => {
warn!("auto-upgrade: failed to check for updates: {err}");
return Ok(AutoUpgradeOutcome::default());
}
};
let Some(latest_version) = info.latest_version.clone() else {
// Already up to date
return Ok(AutoUpgradeOutcome::default());
};
let lock = match AutoUpgradeLock::acquire(&config.code_home) {
Ok(Some(lock)) => lock,
Ok(None) => {
info!("auto-upgrade already in progress by another instance; skipping");
return Ok(AutoUpgradeOutcome::default());
}
Err(err) => {
warn!("auto-upgrade: unable to acquire lock: {err}");
return Ok(AutoUpgradeOutcome::default());
}
};
info!(
command = command_display.as_str(),
latest_version = latest_version.as_str(),
"auto-upgrade: running managed installer"
);
let result = execute_upgrade_command(&command).await;
drop(lock);
let mut outcome = AutoUpgradeOutcome {
installed_version: None,
user_notice: None,
};
match result {
Ok(primary) => {
if primary.success {
info!("auto-upgrade: successfully installed {latest_version}");
outcome.installed_version = Some(latest_version);
return Ok(outcome);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if !starts_with_sudo(&command) {
info!("auto-upgrade: retrying with sudo -n");
let sudo_command = wrap_with_sudo(&command);
match execute_upgrade_command(&sudo_command).await {
Ok(fallback) if fallback.success => {
info!("auto-upgrade: sudo retry succeeded; installed {latest_version}");
outcome.installed_version = Some(latest_version);
return Ok(outcome);
}
Ok(fallback) => {
if sudo_requires_manual_intervention(&fallback.stderr, fallback.status)
{
outcome.user_notice = Some(format!(
"Automatic upgrade needs your attention. Run `/update` to finish with `{}`.",
command_display
));
}
warn!(
"auto-upgrade: sudo retry failed: status={:?} stderr={}",
fallback.status,
truncate_for_log(&fallback.stderr)
);
return Ok(outcome);
}
Err(err) => {
warn!("auto-upgrade: sudo retry error: {err}");
outcome.user_notice = Some(format!(
"Automatic upgrade could not escalate permissions. Run `/update` to finish with `{}`.",
command_display
));
return Ok(outcome);
}
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = primary; // suppress unused warning on non-Unix targets
}
warn!(
"auto-upgrade: upgrade command failed: status={:?} stderr={}",
primary.status,
truncate_for_log(&primary.stderr)
);
Ok(outcome)
}
Err(err) => {
warn!("auto-upgrade: failed to launch upgrade command: {err}");
Ok(outcome)
}
}
}
struct AutoUpgradeLock {
path: PathBuf,
}
impl AutoUpgradeLock {
fn acquire(code_home: &Path) -> anyhow::Result<Option<Self>> {
let path = code_home.join(AUTO_UPGRADE_LOCK_FILE);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
writeln!(file, "{timestamp}")?;
Ok(Some(Self { path }))
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
if Self::is_stale(&path)? {
let _ = fs::remove_file(&path);
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
writeln!(file, "{timestamp}")?;
Ok(Some(Self { path }))
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(None),
Err(err) => Err(err.into()),
}
} else {
Ok(None)
}
}
Err(err) => Err(err.into()),
}
}
fn is_stale(path: &Path) -> anyhow::Result<bool> {
match fs::read_to_string(path) {
Ok(contents) => {
if let Ok(stored) = contents.trim().parse::<u64>() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(now.saturating_sub(stored) > AUTO_UPGRADE_LOCK_TTL.as_secs())
} else {
Ok(true)
}
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(true),
Err(err) => {
warn!("auto-upgrade: failed reading lock file: {err}");
Ok(true)
}
}
}
}
impl Drop for AutoUpgradeLock {
fn drop(&mut self) {
if let Err(err) = fs::remove_file(&self.path) {
if err.kind() != ErrorKind::NotFound {
warn!("auto-upgrade: failed to remove lock file {}: {err}", self.path.display());
}
}
}
}
#[derive(Debug, Clone)]
struct CommandCapture {
success: bool,
status: Option<i32>,
stderr: String,
}
async fn execute_upgrade_command(command: &[String]) -> anyhow::Result<CommandCapture> {
if command.is_empty() {
anyhow::bail!("upgrade command is empty");
}
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let output = cmd.output().await?;
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Ok(CommandCapture {
success: output.status.success(),
status: output.status.code(),
stderr,
})
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn wrap_with_sudo(command: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(command.len() + 3);
out.push("sudo".to_string());
out.push("-n".to_string());
out.push("--".to_string());
out.extend(command.iter().cloned());
out
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn starts_with_sudo(command: &[String]) -> bool {
command
.first()
.map(|c| c.eq_ignore_ascii_case("sudo"))
.unwrap_or(false)
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn sudo_requires_manual_intervention(stderr: &str, status: Option<i32>) -> bool {
let lowered = stderr.to_ascii_lowercase();
let needs_password = lowered.contains("password is required")
|| lowered.contains("a password is required")
|| lowered.contains("no tty present and no askpass program specified")
|| lowered.contains("must be run from a terminal")
|| lowered.contains("may not run sudo")
|| lowered.contains("permission denied");
needs_password && status == Some(1)
}
fn truncate_for_log(text: &str) -> String {
const LIMIT: usize = 256;
const ELLIPSIS_BYTES: usize = '…'.len_utf8();
if text.len() <= LIMIT {
return text.replace('\n', " ");
}
let slice_limit = LIMIT.saturating_sub(ELLIPSIS_BYTES);
let safe_boundary = text
.char_indices()
.map(|(idx, _)| idx)
.chain(std::iter::once(text.len()))
.take_while(|idx| *idx <= slice_limit)
.last()
.unwrap_or(0);
let safe_slice = text.get(..safe_boundary).unwrap_or("");
let mut truncated = safe_slice.to_string();
truncated.push('…');
truncated.replace('\n', " ")
}
fn read_version_info(version_file: &Path) -> anyhow::Result<Option<VersionInfo>> {
let contents = match std::fs::read_to_string(version_file) {
Ok(contents) => contents,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let mut info: VersionInfo = match serde_json::from_str(&contents) {
Ok(info) => info,
Err(err) => {
warn!(
error = %err,
path = %version_file.display(),
"discarding malformed version cache"
);
return Ok(None);
}
};
let repo = info
.release_repo
.as_deref()
.unwrap_or(LEGACY_RELEASE_REPO);
if repo != CURRENT_RELEASE_REPO {
warn!(
path = %version_file.display(),
release_repo = repo,
"stale version info from different repository"
);
return Ok(None);
}
info
.release_repo
.get_or_insert_with(|| CURRENT_RELEASE_REPO.to_string());
Ok(Some(info))
}
fn is_cache_fresh(info: &VersionInfo) -> bool {
let now = Utc::now();
let ahead = info.last_checked_at - now;
if ahead > ChronoDuration::minutes(MAX_CLOCK_SKEW_MINUTES) {
return false;
}
if ahead >= ChronoDuration::zero() {
return true;
}
let age = now - info.last_checked_at;
age < ChronoDuration::hours(CACHE_TTL_HOURS)
}
async fn write_version_info(version_file: &Path, info: &VersionInfo) -> anyhow::Result<()> {
let json_line = format!("{}\n", serde_json::to_string(info)?);
if let Some(parent) = version_file.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let path = version_file.to_path_buf();
task::spawn_blocking(move || -> anyhow::Result<()> {
let parent = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let mut tmp = tempfile::Builder::new()
.prefix("version.json.")
.tempfile_in(&parent)?;
tmp.write_all(json_line.as_bytes())?;
tmp.flush()?;
tmp.persist(&path).map_err(|err| err.error)?;
Ok(())
})
.await??;
Ok(())
}
async fn fetch_latest_version(originator: &str) -> anyhow::Result<VersionInfo> {
#[cfg(test)]
{
let override_fn = FETCH_OVERRIDE.lock().unwrap().clone();
if let Some(fetch) = override_fn {
return fetch(originator).await;
}
}
let ReleaseInfo {
tag_name: latest_tag_name,
} = create_client(originator)
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.await?;
// Support both tagging schemes:
// - "rust-vX.Y.Z" (legacy Rust-release workflow)
// - "vX.Y.Z" (general release workflow)
let latest_version = if let Some(v) = latest_tag_name.strip_prefix("rust-v") {
v.to_string()
} else if let Some(v) = latest_tag_name.strip_prefix('v') {
v.to_string()
} else {
// As a last resort, accept the raw tag if it looks like semver
// so we can recover from unexpected tag formats.
match parse_version(&latest_tag_name) {
Some(_) => latest_tag_name.clone(),
None => anyhow::bail!(
"Failed to parse latest tag name '{}': expected 'rust-vX.Y.Z' or 'vX.Y.Z'",
latest_tag_name
),
}
};
Ok(VersionInfo {
latest_version,
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Result<VersionInfo> {
if let Some(info) = read_version_info(version_file)? {
if is_cache_fresh(&info) {
return Ok(info);
}
}
let _guard = REFRESH_LOCK.lock().await;
if let Some(info) = read_version_info(version_file)? {
if is_cache_fresh(&info) {
return Ok(info);
}
}
let info = fetch_latest_version(originator).await?;
write_version_info(version_file, &info).await?;
Ok(info)
}
#[cfg(test)]
fn with_fetch_override<F, Fut>(fetch: F) -> FetchOverrideGuard
where
F: Fn(&str) -> Fut + Send + Sync + 'static,
Fut: Future<Output = anyhow::Result<VersionInfo>> + Send + 'static,
{
let wrapped: FetchOverrideFn = Arc::new(move |originator: &str| Box::pin(fetch(originator)));
let lock = FETCH_OVERRIDE_TEST_LOCK.lock().unwrap();
*FETCH_OVERRIDE.lock().unwrap() = Some(wrapped);
FetchOverrideGuard {
lock: Some(lock),
}
}
#[cfg(test)]
struct FetchOverrideGuard {
lock: Option<std::sync::MutexGuard<'static, ()>>,
}
#[cfg(test)]
impl Drop for FetchOverrideGuard {
fn drop(&mut self) {
*FETCH_OVERRIDE.lock().unwrap() = None;
if let Some(guard) = self.lock.take() {
drop(guard);
}
}
}
fn is_newer(latest: &str, current: &str) -> Option<bool> {
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => Some(l > c),
_ => None,
}
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut iter = v.trim().split('.');
let maj = iter.next()?.parse::<u64>().ok()?;
let min = iter.next()?.parse::<u64>().ok()?;
let pat = iter.next()?.parse::<u64>().ok()?;
Some((maj, min, pat))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use std::fs;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration as TokioDuration};
fn write_cache(path: &Path, info: &serde_json::Value) {
fs::write(path, format!("{}\n", info)).expect("write version cache");
}
#[test]
fn read_version_info_discard_legacy_repo_cache() {
let dir = tempdir().unwrap();
let path = dir.path().join("version.json");
let legacy = serde_json::json!({
"latest_version": "0.50.0",
"last_checked_at": Utc.timestamp_opt(1_696_000_000, 0).unwrap().to_rfc3339(),
});
write_cache(&path, &legacy);
let result = read_version_info(&path).expect("load cache");
assert!(result.is_none(), "legacy repo cache should be dropped");
}
#[test]
fn read_version_info_accepts_current_repo_cache() {
let dir = tempdir().unwrap();
let path = dir.path().join("version.json");
let info = serde_json::json!({
"latest_version": "0.4.7",
"last_checked_at": Utc::now().to_rfc3339(),
"release_repo": CURRENT_RELEASE_REPO,
});
write_cache(&path, &info);
let parsed = read_version_info(&path)
.expect("load cache")
.expect("current repo cache should load");
assert_eq!(parsed.latest_version, "0.4.7");
assert_eq!(parsed.release_repo.as_deref(), Some(CURRENT_RELEASE_REPO));
}
#[tokio::test]
async fn stale_cache_triggers_network_refresh() {
let dir = tempdir().unwrap();
let version_file = dir.path().join("version.json");
let stale = serde_json::json!({
"latest_version": "0.4.7",
"last_checked_at": (Utc::now() - ChronoDuration::hours(CACHE_TTL_HOURS + 2)).to_rfc3339(),
"release_repo": CURRENT_RELEASE_REPO,
});
write_cache(&version_file, &stale);
let counter = Arc::new(TokioMutex::new(0usize));
let counter_clone = counter.clone();
let expected_version = "0.4.8".to_string();
let expected_clone = expected_version.clone();
let _guard = with_fetch_override(move |_originator| {
let counter = counter_clone.clone();
let version = expected_clone.clone();
async move {
let mut hits = counter.lock().await;
*hits += 1;
Ok(VersionInfo {
latest_version: version,
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
});
let info = check_for_update(&version_file, "test-originator").await.unwrap();
assert_eq!(info.latest_version, expected_version);
assert!(is_cache_fresh(&info));
let persisted = read_version_info(&version_file)
.unwrap()
.expect("updated cache present");
assert_eq!(persisted.latest_version, expected_version);
assert_eq!(persisted.release_repo.as_deref(), Some(CURRENT_RELEASE_REPO));
assert_eq!(*counter.lock().await, 1);
}
#[tokio::test]
async fn fresh_cache_skips_network() {
let dir = tempdir().unwrap();
let version_file = dir.path().join("version.json");
let current = serde_json::json!({
"latest_version": "0.4.7",
"last_checked_at": Utc::now().to_rfc3339(),
"release_repo": CURRENT_RELEASE_REPO,
});
write_cache(&version_file, &current);
let counter = Arc::new(TokioMutex::new(0usize));
let counter_clone = counter.clone();
let _guard = with_fetch_override(move |_originator| {
let counter = counter_clone.clone();
async move {
let mut hits = counter.lock().await;
*hits += 1;
Ok(VersionInfo {
latest_version: "0.4.99".to_string(),
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
});
let info = check_for_update(&version_file, "test-originator").await.unwrap();
assert_eq!(info.latest_version, "0.4.7");
assert_eq!(*counter.lock().await, 0, "no network call expected");
}
#[tokio::test]
async fn concurrent_refreshes_share_single_fetch() {
let dir = tempdir().unwrap();
let version_file = dir.path().join("version.json");
let counter = Arc::new(TokioMutex::new(0usize));
let counter_clone = counter.clone();
let _guard = with_fetch_override(move |_originator| {
let counter = counter_clone.clone();
async move {
let mut hits = counter.lock().await;
*hits += 1;
drop(hits);
sleep(TokioDuration::from_millis(50)).await;
Ok(VersionInfo {
latest_version: "0.5.0".to_string(),
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
});
let tasks: Vec<_> = (0..5)
.map(|_| {
let path = version_file.clone();
async move { check_for_update(&path, "test-originator").await.unwrap() }
})
.collect();
let results = futures::future::join_all(tasks).await;
assert!(results.iter().all(|info| info.latest_version == "0.5.0"));
assert_eq!(*counter.lock().await, 1, "only one fetch should run");
}
#[tokio::test]
async fn malformed_cache_is_replaced() {
let dir = tempdir().unwrap();
let version_file = dir.path().join("version.json");
fs::write(&version_file, "not json").unwrap();
let counter = Arc::new(TokioMutex::new(0usize));
let counter_clone = counter.clone();
let _guard = with_fetch_override(move |_originator| {
let counter = counter_clone.clone();
async move {
let mut hits = counter.lock().await;
*hits += 1;
Ok(VersionInfo {
latest_version: "0.5.1".to_string(),
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
});
let info = check_for_update(&version_file, "test-originator").await.unwrap();
assert_eq!(info.latest_version, "0.5.1");
assert_eq!(*counter.lock().await, 1);
let persisted = read_version_info(&version_file)
.unwrap()
.expect("cache rewritten");
assert_eq!(persisted.latest_version, "0.5.1");
}
#[tokio::test]
async fn write_fails_when_parent_is_file() {
let dir = tempdir().unwrap();
let blocker = dir.path().join("cache");
fs::write(&blocker, "not a directory").unwrap();
let version_file = blocker.join("version.json");
let counter = Arc::new(TokioMutex::new(0usize));
let counter_clone = counter.clone();
let _guard = with_fetch_override(move |_originator| {
let counter = counter_clone.clone();
async move {
let mut hits = counter.lock().await;
*hits += 1;
Ok(VersionInfo {
latest_version: "0.5.2".to_string(),
last_checked_at: Utc::now(),
release_repo: Some(CURRENT_RELEASE_REPO.to_string()),
})
}
});
let err = check_for_update(&version_file, "test-originator")
.await
.expect_err("write should fail");
let io_err = err
.downcast_ref::<std::io::Error>()
.or_else(|| err.root_cause().downcast_ref::<std::io::Error>())
.expect("io error expected");
assert!(matches!(
io_err.kind(),
ErrorKind::AlreadyExists | ErrorKind::PermissionDenied | ErrorKind::NotADirectory
));
assert_eq!(*counter.lock().await, 0, "fetch should not run on path errors");
}
}