use anyhow::{Context, Result};
use std::env;
use std::fs;
use std::path::Path;
#[cfg(windows)]
use std::io::Write;
#[cfg(windows)]
use std::process::{Command, Stdio};
pub fn get_asset_pattern(version: &str) -> Result<String> {
let os = env::consts::OS;
let arch = env::consts::ARCH;
let arch_name = match arch {
"x86_64" => "x64",
"aarch64" => "arm64",
other => other,
};
let pattern = match os {
"linux" => format!(
"vibe_coding_tracker-v{}-linux-{}-gnu.tar.gz",
version, arch_name
),
"macos" => format!(
"vibe_coding_tracker-v{}-macos-{}.tar.gz",
version, arch_name
),
"windows" => format!("vibe_coding_tracker-v{}-windows-{}.zip", version, arch_name),
_ => {
anyhow::bail!("Unsupported platform: {}-{}", os, arch);
}
};
Ok(pattern)
}
#[cfg(unix)]
pub fn stage_update_unix(current_exe: &Path, new_binary: &Path) -> Result<tempfile::TempPath> {
let parent = current_exe
.parent()
.context("Current executable has no parent directory")?;
let staged = tempfile::Builder::new()
.prefix(".vct-update-")
.tempfile_in(parent)
.context("Failed to create sibling update file")?;
fs::copy(new_binary, staged.path()).context("Failed to stage new binary")?;
fs::set_permissions(staged.path(), fs::metadata(new_binary)?.permissions())
.context("Failed to preserve new binary permissions")?;
staged
.as_file()
.sync_all()
.context("Failed to sync new binary")?;
Ok(staged.into_temp_path())
}
#[cfg(unix)]
pub fn commit_update_unix(current_exe: &Path, staged: tempfile::TempPath) -> Result<()> {
fs::rename(&staged, current_exe)
.context("Failed to atomically replace binary with new version")?;
Ok(())
}
#[cfg(windows)]
pub fn perform_update_windows(
current_exe: &Path,
new_binary: &Path,
expected_version: &str,
lock_path: &Path,
) -> Result<()> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let parent = current_exe
.parent()
.context("Current executable has no parent directory")?;
let mut staged = tempfile::Builder::new()
.prefix(".vct-update-")
.suffix(".new")
.tempfile_in(parent)
.context("Failed to create sibling update file")?;
let mut source = fs::File::open(new_binary).context("Failed to open new binary")?;
std::io::copy(&mut source, staged.as_file_mut()).context("Failed to stage new binary")?;
staged
.as_file()
.sync_all()
.context("Failed to sync new binary")?;
let staged_path = staged.path().to_path_buf();
let mut helper = tempfile::Builder::new()
.prefix(".vct-update-")
.suffix(".ps1")
.tempfile_in(parent)
.context("Failed to create update helper")?;
helper
.write_all(
windows_update_script(
current_exe,
&staged_path,
expected_version,
lock_path,
std::process::id(),
)
.as_bytes(),
)
.context("Failed to write update helper")?;
helper
.as_file()
.sync_all()
.context("Failed to sync update helper")?;
let (_, staged_path) = staged.keep().context("Failed to retain staged binary")?;
let (_, helper_path) = match helper.keep() {
Ok(kept) => kept,
Err(error) => {
let _ = fs::remove_file(&staged_path);
return Err(error).context("Failed to retain update helper");
}
};
let spawn = Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(&helper_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn();
if let Err(error) = spawn {
let _ = fs::remove_file(&staged_path);
let _ = fs::remove_file(&helper_path);
return Err(error).context("Failed to start update helper");
}
Ok(())
}
#[cfg(any(windows, test))]
fn windows_update_script(
current_exe: &Path,
staged: &Path,
expected_version: &str,
lock_path: &Path,
parent_pid: u32,
) -> String {
const TEMPLATE: &str = r#"$ErrorActionPreference = 'Stop'
$target = '__TARGET__'
$staged = '__STAGED__'
$lockPath = '__LOCK_PATH__'
$expected = [Version]'__EXPECTED_VERSION__'
$parentPid = __PARENT_PID__
$lock = $null
$replaced = $false
try {
for ($attempt = 0; $attempt -lt 1200 -and $null -eq $lock; $attempt++) {
try {
$lock = [System.IO.File]::Open(
$lockPath,
[System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite,
[System.IO.FileShare]::None
)
} catch [System.IO.IOException] {
Start-Sleep -Milliseconds 100
}
}
if ($null -eq $lock) {
throw 'Timed out while waiting for the update lock'
}
while (Get-Process -Id $parentPid -ErrorAction SilentlyContinue) {
Start-Sleep -Milliseconds 250
}
$installed = $null
try {
$reported = & $target --version 2>$null | Select-Object -First 1
if ($LASTEXITCODE -eq 0 -and $null -ne $reported) {
$baseVersion = ([string]$reported).Trim().TrimStart('v').Split('-')[0]
$installed = [Version]::Parse($baseVersion)
}
} catch {
$installed = $null
}
if ($null -eq $installed -or $installed -le $expected) {
for ($attempt = 0; $attempt -lt 120 -and -not $replaced; $attempt++) {
try {
Move-Item -LiteralPath $staged -Destination $target -Force
$replaced = $true
} catch [System.IO.IOException] {
Start-Sleep -Seconds 1
} catch [System.UnauthorizedAccessException] {
Start-Sleep -Seconds 1
}
}
}
} catch {
$replaced = $false
} finally {
if (-not $replaced) {
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
}
if ($null -ne $lock) {
$lock.Dispose()
}
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
}
"#;
TEMPLATE
.replace("__TARGET__", &powershell_literal(current_exe))
.replace("__STAGED__", &powershell_literal(staged))
.replace("__LOCK_PATH__", &powershell_literal(lock_path))
.replace("__EXPECTED_VERSION__", expected_version)
.replace("__PARENT_PID__", &parent_pid.to_string())
}
#[cfg(any(windows, test))]
fn powershell_literal(path: &Path) -> String {
path.to_string_lossy().replace('\'', "''")
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn unix_replacement_is_atomic_and_leaves_no_fixed_backup() {
let dir = tempfile::tempdir().unwrap();
let current = dir.path().join("vibe_coding_tracker");
let candidate = dir.path().join("candidate");
fs::write(¤t, b"old").unwrap();
fs::write(&candidate, b"new").unwrap();
let staged = stage_update_unix(¤t, &candidate).unwrap();
assert_eq!(staged.parent(), current.parent());
commit_update_unix(¤t, staged).unwrap();
assert_eq!(fs::read(¤t).unwrap(), b"new");
assert!(!current.with_extension("old").exists());
}
#[cfg(unix)]
#[test]
fn unix_staging_failure_keeps_the_old_binary() {
let dir = tempfile::tempdir().unwrap();
let current = dir.path().join("vibe_coding_tracker");
fs::write(¤t, b"old").unwrap();
assert!(stage_update_unix(¤t, &dir.path().join("missing")).is_err());
assert_eq!(fs::read(¤t).unwrap(), b"old");
}
#[cfg(unix)]
#[test]
fn unix_rename_failure_keeps_the_old_target() {
let dir = tempfile::tempdir().unwrap();
let current = dir.path().join("vibe_coding_tracker");
let candidate = dir.path().join("candidate");
fs::create_dir(¤t).unwrap();
fs::write(current.join("sentinel"), b"old").unwrap();
fs::write(&candidate, b"new").unwrap();
let staged = stage_update_unix(¤t, &candidate).unwrap();
assert!(commit_update_unix(¤t, staged).is_err());
assert_eq!(fs::read(current.join("sentinel")).unwrap(), b"old");
}
#[test]
fn windows_helper_holds_the_lock_and_prevents_downgrades() {
let script = windows_update_script(
Path::new(r"C:\Program Files\VCT\vibe_coding_tracker.exe"),
Path::new(r"C:\Program Files\VCT\candidate.new"),
"2.5.0",
Path::new(r"C:\Program Files\VCT\.vct-update.lock"),
4242,
);
assert!(script.contains("$parentPid = 4242"));
assert!(script.contains("[System.IO.FileShare]::None"));
assert!(script.contains("Start-Sleep -Milliseconds 250"));
assert!(script.contains("$installed -le $expected"));
assert!(script.contains("$attempt -lt 120"));
assert!(script.contains("$target = 'C:\\Program Files\\VCT\\vibe_coding_tracker.exe'"));
assert!(script.contains("$staged = 'C:\\Program Files\\VCT\\candidate.new'"));
assert!(script.contains("$lockPath = 'C:\\Program Files\\VCT\\.vct-update.lock'"));
assert!(script.contains("$expected = [Version]'2.5.0'"));
assert!(!script.contains("timeout /T"));
}
#[test]
fn windows_helper_escapes_single_quotes_in_paths() {
let script = windows_update_script(
Path::new(r"C:\Wei's Tools\vibe_coding_tracker.exe"),
Path::new(r"C:\Wei's Tools\candidate.new"),
"2.5.0",
Path::new(r"C:\Wei's Tools\.vct-update.lock"),
4242,
);
assert!(script.contains(r"$target = 'C:\Wei''s Tools\vibe_coding_tracker.exe'"));
assert!(script.contains(r"$staged = 'C:\Wei''s Tools\candidate.new'"));
}
}