use std::io;
use std::path::{Path, PathBuf};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use serde::Deserialize;
use super::{
CRATE_NAME, CRATES_IO_API, GITHUB_RELEASES_API, RELEASE_BASE_URL, UpdateState, bytes_sha256,
colors, confirm_action, icons, is_newer, now_iso,
};
use crate::cli::{CRT_DRAW_MS, heading, theme};
pub(super) fn parse_semver(v: &str) -> (u32, u32, u32) {
let v = v.trim_start_matches('v');
let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
let v = v.split_once('-').map(|(core, _)| core).unwrap_or(v);
let parts: Vec<&str> = v.split('.').collect();
let major = parts
.first()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
tracing::warn!(version = v, "failed to parse major version component");
0
});
let minor = parts
.get(1)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
tracing::warn!(version = v, "failed to parse minor version component");
0
});
let patch = parts
.get(2)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
tracing::warn!(version = v, "failed to parse patch version component");
0
});
(major, minor, patch)
}
fn platform_archive_name(version: &str) -> Option<String> {
let (arch, os, ext) = platform_archive_parts()?;
Some(format!("roboticus-{version}-{arch}-{os}.{ext}"))
}
fn platform_archive_parts() -> Option<(&'static str, &'static str, &'static str)> {
let arch = match std::env::consts::ARCH {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
_ => return None,
};
let os = match std::env::consts::OS {
"linux" => "linux",
"macos" => "darwin",
"windows" => "windows",
_ => return None,
};
let ext = if os == "windows" { "zip" } else { "tar.gz" };
Some((arch, os, ext))
}
fn parse_sha256sums_for_artifact(sha256sums: &str, artifact: &str) -> Option<String> {
for raw in sha256sums.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let file = parts.next()?;
if file == artifact {
return Some(hash.to_ascii_lowercase());
}
}
None
}
#[derive(Debug, Clone, Deserialize)]
struct GitHubRelease {
tag_name: String,
draft: bool,
prerelease: bool,
published_at: Option<String>,
assets: Vec<GitHubAsset>,
}
#[derive(Debug, Clone, Deserialize)]
struct GitHubAsset {
name: String,
}
fn core_version(s: &str) -> &str {
let s = s.trim_start_matches('v');
let s = s.split_once('+').map(|(core, _)| core).unwrap_or(s);
s.split_once('-').map(|(core, _)| core).unwrap_or(s)
}
fn archive_suffixes(arch: &str, os: &str, ext: &str) -> Vec<String> {
let mut suffixes = vec![format!("-{arch}-{os}.{ext}")];
if os == "darwin" {
suffixes.push(format!("-{arch}-macos.{ext}"));
} else if os == "macos" {
suffixes.push(format!("-{arch}-darwin.{ext}"));
}
suffixes
}
fn select_archive_asset_name(release: &GitHubRelease, version: &str) -> Option<String> {
let (arch, os, ext) = platform_archive_parts()?;
let core_prefix = format!("roboticus-{}", core_version(version));
for suffix in archive_suffixes(arch, os, ext) {
let exact = format!("{core_prefix}{suffix}");
if release.assets.iter().any(|a| a.name == exact) {
return Some(exact);
}
}
let suffixes = archive_suffixes(arch, os, ext);
release.assets.iter().find_map(|a| {
if a.name.starts_with(&core_prefix) && suffixes.iter().any(|s| a.name.ends_with(s)) {
Some(a.name.clone())
} else {
None
}
})
}
fn release_supports_platform(release: &GitHubRelease, version: &str) -> bool {
release.assets.iter().any(|a| a.name == "SHA256SUMS.txt")
&& select_archive_asset_name(release, version).is_some()
}
fn select_release_for_download(
releases: &[GitHubRelease],
version: &str,
current_version: &str,
) -> Option<(String, String)> {
let canonical = format!("v{version}");
if let Some(exact) = releases
.iter()
.find(|r| !r.draft && !r.prerelease && r.tag_name == canonical)
&& release_supports_platform(exact, version)
&& let Some(archive) = select_archive_asset_name(exact, version)
{
return Some((exact.tag_name.clone(), archive));
}
if let Some(best_same_core) = releases
.iter()
.filter(|r| !r.draft && !r.prerelease)
.filter(|r| core_version(&r.tag_name) == core_version(version))
.filter(|r| release_supports_platform(r, version))
.filter_map(|r| select_archive_asset_name(r, version).map(|archive| (r, archive)))
.max_by_key(|(r, _)| r.published_at.as_deref().unwrap_or(""))
.map(|(r, archive)| (r.tag_name.clone(), archive))
{
return Some(best_same_core);
}
releases
.iter()
.filter(|r| !r.draft && !r.prerelease)
.filter(|r| is_newer(core_version(&r.tag_name), current_version))
.filter(|r| release_supports_platform(r, core_version(&r.tag_name)))
.filter_map(|r| {
let release_version = core_version(&r.tag_name);
select_archive_asset_name(r, release_version).map(|archive| (r, archive))
})
.max_by_key(|(r, _)| parse_semver(core_version(&r.tag_name)))
.map(|(r, archive)| (r.tag_name.clone(), archive))
}
async fn resolve_download_release(
client: &reqwest::Client,
version: &str,
current_version: &str,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let resp = client.get(GITHUB_RELEASES_API).send().await?;
if !resp.status().is_success() {
return Err(format!("Failed to query GitHub releases: HTTP {}", resp.status()).into());
}
let releases: Vec<GitHubRelease> = resp.json().await?;
select_release_for_download(&releases, version, current_version).ok_or_else(|| {
format!(
"No downloadable release found for v{version} with required platform archive and SHA256SUMS.txt"
)
.into()
})
}
fn find_file_recursive(root: &Path, filename: &str) -> io::Result<Option<PathBuf>> {
find_file_recursive_depth(root, filename, 10)
}
fn find_file_recursive_depth(
root: &Path,
filename: &str,
remaining_depth: usize,
) -> io::Result<Option<PathBuf>> {
if remaining_depth == 0 {
return Ok(None);
}
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_file_recursive_depth(&path, filename, remaining_depth - 1)? {
return Ok(Some(found));
}
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == filename)
.unwrap_or(false)
{
return Ok(Some(path));
}
}
Ok(None)
}
fn install_binary_bytes(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let exe = std::env::current_exe()?;
#[cfg(windows)]
{
let old_exe = exe.with_extension("exe.old");
let _ = std::fs::remove_file(&old_exe);
std::fs::rename(&exe, &old_exe).map_err(|e| {
format!(
"failed to rename running binary to {}: {e} — \
try closing all roboticus processes and retry",
old_exe.display()
)
})?;
if let Err(e) = std::fs::write(&exe, bytes) {
let _ = std::fs::rename(&old_exe, &exe);
return Err(format!("failed to write new binary: {e}").into());
}
tracing::info!(
old = %old_exe.display(),
new = %exe.display(),
"binary replaced via rename strategy; .old will be cleaned on next launch"
);
return Ok(());
}
#[cfg(not(windows))]
{
let tmp = exe.with_extension("new");
std::fs::write(&tmp, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&exe)
.map(|m| m.permissions().mode())
.unwrap_or(0o755);
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
}
std::fs::rename(&tmp, &exe)?;
Ok(())
}
}
pub fn cleanup_old_binary() {
if let Ok(exe) = std::env::current_exe() {
let old = exe.with_extension("exe.old");
if old.exists() {
match std::fs::remove_file(&old) {
Ok(()) => tracing::debug!(path = %old.display(), "cleaned up old binary"),
Err(e) => {
tracing::debug!(path = %old.display(), error = %e, "could not clean old binary (may still be in use)")
}
}
}
}
}
async fn apply_binary_download_update(
client: &reqwest::Client,
latest: &str,
current: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let _archive_probe = platform_archive_name(latest).ok_or_else(|| {
format!(
"No release archive mapping for platform {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let (tag, archive) = resolve_download_release(client, latest, current).await?;
let sha_url = format!("{RELEASE_BASE_URL}/{tag}/SHA256SUMS.txt");
let archive_url = format!("{RELEASE_BASE_URL}/{tag}/{archive}");
let sha_resp = client.get(&sha_url).send().await?;
if !sha_resp.status().is_success() {
return Err(format!("Failed to fetch SHA256SUMS.txt: HTTP {}", sha_resp.status()).into());
}
let sha_body = sha_resp.text().await?;
let expected = parse_sha256sums_for_artifact(&sha_body, &archive)
.ok_or_else(|| format!("No checksum found for artifact {archive}"))?;
let archive_resp = client.get(&archive_url).send().await?;
if !archive_resp.status().is_success() {
return Err(format!(
"Failed to download release archive: HTTP {}",
archive_resp.status()
)
.into());
}
let archive_bytes = archive_resp.bytes().await?.to_vec();
let actual = bytes_sha256(&archive_bytes);
if actual != expected {
return Err(
format!("SHA256 mismatch for {archive}: expected {expected}, got {actual}").into(),
);
}
let temp_root = std::env::temp_dir().join(format!(
"roboticus-update-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_millis()
));
std::fs::create_dir_all(&temp_root)?;
let archive_path = if archive.ends_with(".zip") {
temp_root.join("roboticus.zip")
} else {
temp_root.join("roboticus.tar.gz")
};
std::fs::write(&archive_path, &archive_bytes)?;
if archive.ends_with(".zip") {
let status = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&format!(
"Expand-Archive -Path \"{}\" -DestinationPath \"{}\" -Force",
archive_path.display(),
temp_root.display()
),
])
.status()?;
if !status.success() {
let _ = std::fs::remove_dir_all(&temp_root);
return Err(
format!("Failed to extract {archive} with PowerShell Expand-Archive").into(),
);
}
} else {
let status = std::process::Command::new("tar")
.arg("-xzf")
.arg(&archive_path)
.arg("-C")
.arg(&temp_root)
.status()?;
if !status.success() {
let _ = std::fs::remove_dir_all(&temp_root);
return Err(format!("Failed to extract {archive} with tar").into());
}
}
let bin_name = if std::env::consts::OS == "windows" {
"roboticus.exe"
} else {
"roboticus"
};
let extracted = find_file_recursive(&temp_root, bin_name)?
.ok_or_else(|| format!("Could not locate extracted {bin_name} binary"))?;
let bytes = std::fs::read(&extracted)?;
install_binary_bytes(&bytes)?;
let _ = std::fs::remove_dir_all(&temp_root);
Ok(())
}
fn c_compiler_available() -> bool {
#[cfg(windows)]
{
if std::process::Command::new("cmd")
.args(["/C", "where", "cl"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
if std::process::Command::new("gcc")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
#[allow(clippy::needless_return)]
return std::process::Command::new("clang")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false);
}
#[cfg(not(windows))]
{
if std::process::Command::new("cc")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
if std::process::Command::new("clang")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
std::process::Command::new("gcc")
.arg("--version")
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
#[cfg(windows)]
fn apply_binary_cargo_update_detached(latest: &str) -> bool {
let (_, _, _, _, _, _, _, _, _) = colors();
let (OK, _, WARN, DETAIL, ERR) = icons();
if !c_compiler_available() {
println!(" {WARN} Local build toolchain check failed: no C compiler found in PATH");
println!(
" {DETAIL} `--method build` requires a C compiler (and related native build tools)."
);
println!(" {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc.");
return false;
}
let staging_dir = std::env::temp_dir().join(format!(
"roboticus-build-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_millis()
));
if std::fs::create_dir_all(&staging_dir).is_err() {
println!(" {ERR} Could not create staging directory");
return false;
}
let log_file = staging_dir.join("cargo-build-update.log");
let script_path = staging_dir.join("cargo-build-update.cmd");
let cargo_exe = which_cargo().unwrap_or_else(|| "cargo".to_string());
let script = format!(
"@echo off\r\n\
setlocal\r\n\
set LOG={log}\r\n\
echo [%DATE% %TIME%] Waiting for roboticus process to exit... >> \"%LOG%\"\r\n\
:wait\r\n\
tasklist /FI \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul && (\r\n\
timeout /t 1 /nobreak >nul\r\n\
goto :wait\r\n\
)\r\n\
echo [%DATE% %TIME%] Process exited, starting cargo install... >> \"%LOG%\"\r\n\
\"{cargo}\" install {crate_name} --version {version} --force >> \"%LOG%\" 2>&1\r\n\
if errorlevel 1 (\r\n\
echo [%DATE% %TIME%] FAILED: cargo install exited with error >> \"%LOG%\"\r\n\
echo.\r\n\
echo Roboticus build update FAILED. See log: %LOG%\r\n\
pause\r\n\
exit /b 1\r\n\
)\r\n\
echo [%DATE% %TIME%] SUCCESS: binary updated to v{version} >> \"%LOG%\"\r\n\
echo.\r\n\
echo Roboticus updated to v{version} successfully.\r\n\
timeout /t 5 /nobreak >nul\r\n\
exit /b 0\r\n",
log = log_file.display(),
pid = std::process::id(),
cargo = cargo_exe,
crate_name = CRATE_NAME,
version = latest,
);
if std::fs::write(&script_path, &script).is_err() {
println!(" {ERR} Could not write build script");
return false;
}
match std::process::Command::new("cmd")
.args(["/C", "start", "\"Roboticus Update\"", "/MIN"])
.arg(script_path.to_string_lossy().as_ref())
.creation_flags(0x00000008) .spawn()
{
Ok(_) => {
println!(" {OK} Build update spawned in background");
println!(" {DETAIL} This process will exit so the file lock is released.");
println!(
" {DETAIL} A console window will show build progress. Log: {}",
log_file.display()
);
println!(
" {DETAIL} Re-run `roboticus version` after the build completes to confirm."
);
true
}
Err(e) => {
println!(" {ERR} Failed to spawn detached build: {e}");
println!(
" {DETAIL} Run `cargo install {CRATE_NAME} --force` manually from a separate shell."
);
false
}
}
}
#[cfg(windows)]
fn which_cargo() -> Option<String> {
std::process::Command::new("cmd")
.args(["/C", "where", "cargo"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout)
.ok()
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
} else {
None
}
})
}
fn apply_binary_cargo_update(latest: &str) -> bool {
let (DIM, _, _, _, _, _, _, RESET, _) = colors();
let (OK, _, WARN, DETAIL, ERR) = icons();
if !c_compiler_available() {
println!(" {WARN} Local build toolchain check failed: no C compiler found in PATH");
println!(
" {DETAIL} `--method build` requires a C compiler (and related native build tools)."
);
println!(
" {DETAIL} Recommended: use `roboticus update binary --method download --yes`."
);
#[cfg(windows)]
{
println!(
" {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc."
);
}
#[cfg(target_os = "macos")]
{
println!(" {DETAIL} macOS: run `xcode-select --install`.");
}
#[cfg(target_os = "linux")]
{
println!(
" {DETAIL} Linux: install build tools (for example `build-essential` on Debian/Ubuntu)."
);
}
return false;
}
println!(" Installing v{latest} via cargo install...");
println!(" {DIM}This may take a few minutes.{RESET}");
let status = std::process::Command::new("cargo")
.args(["install", CRATE_NAME])
.status();
match status {
Ok(s) if s.success() => {
println!(" {OK} Binary updated to v{latest}");
true
}
Ok(s) => {
println!(
" {ERR} cargo install exited with code {}",
s.code().unwrap_or(-1)
);
false
}
Err(e) => {
println!(" {ERR} Failed to run cargo install: {e}");
println!(" {DIM}Ensure cargo is in your PATH{RESET}");
false
}
}
}
pub(crate) async fn check_binary_version(
client: &reqwest::Client,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let resp = client.get(CRATES_IO_API).send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = resp.json().await?;
let latest = body
.pointer("/crate/max_version")
.and_then(|v| v.as_str())
.map(String::from);
Ok(latest)
}
pub(super) async fn apply_binary_update(
yes: bool,
method: &str,
force: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
let (OK, _, WARN, DETAIL, ERR) = icons();
let current = env!("CARGO_PKG_VERSION");
let client = super::http_client()?;
let method = method.to_ascii_lowercase();
println!("\n {BOLD}Binary Update{RESET}\n");
println!(" Current version: {MONO}v{current}{RESET}");
let latest = match check_binary_version(&client).await? {
Some(v) => v,
None => {
println!(" {WARN} Could not reach crates.io");
return Ok(false);
}
};
println!(" Latest version: {MONO}v{latest}{RESET}");
if force {
println!(" --force: skipping version check, forcing reinstall");
} else if !is_newer(&latest, current) {
println!(" {OK} Already on latest version");
return Ok(false);
}
println!(" {GREEN}New version available: v{latest}{RESET}");
println!();
if std::env::consts::OS == "windows" && method == "build" {
if !yes
&& !confirm_action(
"Build on Windows requires a detached process (this session will exit). Proceed?",
true,
)
{
println!(" Skipped.");
return Ok(false);
}
#[cfg(windows)]
{
return Ok(apply_binary_cargo_update_detached(&latest));
}
#[cfg(not(windows))]
{
return Ok(false);
}
}
if !yes && !confirm_action("Proceed with binary update?", true) {
println!(" Skipped.");
return Ok(false);
}
let mut updated = false;
if method == "download" {
println!(" Attempting platform binary download + fingerprint verification...");
match apply_binary_download_update(&client, &latest, current).await {
Ok(()) => {
println!(" {OK} Binary downloaded and verified (SHA256)");
if std::env::consts::OS == "windows" {
println!(
" {DETAIL} Update staged. The replacement finalizes after this process exits."
);
println!(
" {DETAIL} Re-run `roboticus version` in a few seconds to confirm."
);
}
updated = true;
}
Err(e) => {
println!(" {WARN} Download update failed: {e}");
if std::env::consts::OS == "windows" {
if confirm_action(
"Download failed. Fall back to cargo build? (spawns detached process, this session exits)",
true,
) {
#[cfg(windows)]
{
updated = apply_binary_cargo_update_detached(&latest);
}
} else {
println!(" Skipped fallback build.");
}
} else if confirm_action(
"Download failed. Fall back to cargo build update? (slower, compiles from source)",
true,
) {
updated = apply_binary_cargo_update(&latest);
} else {
println!(" Skipped fallback build.");
}
}
}
} else {
updated = apply_binary_cargo_update(&latest);
}
if updated {
println!(" {OK} Binary updated to v{latest}");
let mut state = UpdateState::load();
state.binary_version = latest;
state.last_check = now_iso();
state
.save()
.inspect_err(
|e| tracing::warn!(error = %e, "failed to save update state after version check"),
)
.ok();
Ok(true)
} else {
if method == "download" {
println!(" {ERR} Binary update did not complete");
}
Ok(false)
}
}
pub async fn cmd_update_binary(
_channel: &str,
yes: bool,
method: &str,
hygiene_fn: Option<&super::HygieneFn>,
) -> Result<(), Box<dyn std::error::Error>> {
heading("Roboticus Binary Update");
apply_binary_update(yes, method, false).await?;
super::run_oauth_storage_maintenance();
let config_path = roboticus_core::config::resolve_config_path(None).unwrap_or_else(|| {
roboticus_core::home_dir()
.join(".roboticus")
.join("roboticus.toml")
});
super::run_mechanic_checks_maintenance(&config_path.to_string_lossy(), hygiene_fn);
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semver_parse_basic() {
assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
assert_eq!(parse_semver("v0.1.0"), (0, 1, 0));
assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
}
#[test]
fn is_newer_works() {
assert!(is_newer("0.2.0", "0.1.0"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(!is_newer("0.1.0", "0.1.0"));
assert!(!is_newer("0.1.0", "0.2.0"));
}
#[test]
fn is_newer_patch_bump() {
assert!(is_newer("1.0.1", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.1"));
}
#[test]
fn is_newer_same_version() {
assert!(!is_newer("1.0.0", "1.0.0"));
}
#[test]
fn platform_archive_name_supported() {
let name = platform_archive_name("1.2.3");
if let Some(n) = name {
assert!(n.contains("roboticus-1.2.3-"));
}
}
#[test]
fn parse_semver_partial_version() {
assert_eq!(parse_semver("1"), (1, 0, 0));
assert_eq!(parse_semver("1.2"), (1, 2, 0));
}
#[test]
fn parse_semver_empty() {
assert_eq!(parse_semver(""), (0, 0, 0));
}
#[test]
fn parse_semver_with_v_prefix() {
assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
}
#[test]
fn parse_semver_ignores_build_and_prerelease_metadata() {
assert_eq!(parse_semver("0.9.4+hotfix.1"), (0, 9, 4));
assert_eq!(parse_semver("v1.2.3-rc.1"), (1, 2, 3));
}
#[test]
fn parse_sha256sums_for_artifact_finds_exact_entry() {
let sums = "\
abc123 roboticus-0.8.0-darwin-aarch64.tar.gz\n\
def456 roboticus-0.8.0-linux-x86_64.tar.gz\n";
let hash = parse_sha256sums_for_artifact(sums, "roboticus-0.8.0-linux-x86_64.tar.gz");
assert_eq!(hash.as_deref(), Some("def456"));
}
#[test]
fn find_file_recursive_finds_nested_target() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let target = nested.join("needle.txt");
std::fs::write(&target, "x").unwrap();
let found = find_file_recursive(dir.path(), "needle.txt").unwrap();
assert_eq!(found.as_deref(), Some(target.as_path()));
}
#[test]
fn parse_sha256sums_for_artifact_returns_none_when_missing() {
let sums = "abc123 file-a.tar.gz\n";
assert!(parse_sha256sums_for_artifact(sums, "file-b.tar.gz").is_none());
}
#[test]
fn select_release_for_download_prefers_exact_tag() {
let archive = platform_archive_name("0.9.4").unwrap();
let releases = vec![
GitHubRelease {
tag_name: "v0.9.4+hotfix.1".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-05T11:36:51Z".into()),
assets: vec![
GitHubAsset {
name: "SHA256SUMS.txt".into(),
},
GitHubAsset {
name: format!(
"roboticus-0.9.4+hotfix.1-{}",
&archive["roboticus-0.9.4-".len()..]
),
},
],
},
GitHubRelease {
tag_name: "v0.9.4".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-05T10:00:00Z".into()),
assets: vec![
GitHubAsset {
name: "SHA256SUMS.txt".into(),
},
GitHubAsset {
name: archive.clone(),
},
],
},
];
let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
assert_eq!(
selected.as_ref().map(|(tag, _)| tag.as_str()),
Some("v0.9.4")
);
}
#[test]
fn select_release_for_download_falls_back_to_hotfix_tag() {
let archive = platform_archive_name("0.9.4").unwrap();
let suffix = &archive["roboticus-0.9.4-".len()..];
let releases = vec![
GitHubRelease {
tag_name: "v0.9.4".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-05T10:00:00Z".into()),
assets: vec![GitHubAsset {
name: "PROVENANCE.json".into(),
}],
},
GitHubRelease {
tag_name: "v0.9.4+hotfix.2".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-05T12:00:00Z".into()),
assets: vec![
GitHubAsset {
name: "SHA256SUMS.txt".into(),
},
GitHubAsset {
name: format!("roboticus-0.9.4+hotfix.2-{suffix}"),
},
],
},
];
let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
let expected_archive = format!("roboticus-0.9.4+hotfix.2-{suffix}");
assert_eq!(
selected.as_ref().map(|(tag, _)| tag.as_str()),
Some("v0.9.4+hotfix.2")
);
assert_eq!(
selected
.as_ref()
.map(|(_, archive_name)| archive_name.as_str()),
Some(expected_archive.as_str())
);
}
#[test]
fn select_release_for_download_falls_back_to_latest_compatible_version() {
let archive_010 = platform_archive_name("0.10.0").unwrap();
let archive_099 = platform_archive_name("0.9.9").unwrap();
let releases = vec![
GitHubRelease {
tag_name: "v0.10.0".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-23T12:00:00Z".into()),
assets: vec![GitHubAsset {
name: "SHA256SUMS.txt".into(),
}],
},
GitHubRelease {
tag_name: "v0.9.9".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-20T12:00:00Z".into()),
assets: vec![
GitHubAsset {
name: "SHA256SUMS.txt".into(),
},
GitHubAsset { name: archive_099 },
],
},
GitHubRelease {
tag_name: "v0.9.8".into(),
draft: false,
prerelease: false,
published_at: Some("2026-03-17T12:00:00Z".into()),
assets: vec![
GitHubAsset {
name: "SHA256SUMS.txt".into(),
},
GitHubAsset { name: archive_010 },
],
},
];
let selected = select_release_for_download(&releases, "0.10.0", "0.9.7");
assert_eq!(
selected.as_ref().map(|(tag, _)| tag.as_str()),
Some("v0.9.9")
);
}
#[test]
fn archive_suffixes_include_macos_alias_for_darwin() {
let suffixes = archive_suffixes("aarch64", "darwin", "tar.gz");
assert!(suffixes.contains(&"-aarch64-darwin.tar.gz".to_string()));
assert!(suffixes.contains(&"-aarch64-macos.tar.gz".to_string()));
}
#[test]
fn find_file_recursive_returns_none_when_not_found() {
let dir = tempfile::tempdir().unwrap();
let found = find_file_recursive(dir.path(), "does-not-exist.txt").unwrap();
assert!(found.is_none());
}
}