use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const REPO: &str = "ushironoko/octorus";
#[allow(dead_code)]
pub enum UpdateResult {
Updated { version: String },
AlreadyCurrent,
ManualActionRequired { message: String },
}
pub(crate) fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return None;
}
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
}
pub(crate) fn is_newer_version(current: &str, latest: &str) -> bool {
match (parse_semver(current), parse_semver(latest)) {
(Some(cur), Some(lat)) => lat > cur,
_ => false, }
}
#[derive(Debug, PartialEq)]
enum InstallMethod {
CargoInstall,
Mise,
GitHubRelease,
Unknown,
}
const KNOWN_RELEASE_DIRS: &[&str] = &["/usr/local/bin", "/opt/homebrew/bin"];
fn detect_install_method(exe_path: &Path) -> InstallMethod {
if is_path_under_cargo_bin(exe_path) {
return InstallMethod::CargoInstall;
}
if is_path_under_mise(exe_path) {
return InstallMethod::Mise;
}
if is_known_release_location(exe_path) {
return InstallMethod::GitHubRelease;
}
InstallMethod::Unknown
}
fn is_known_release_location(exe_path: &Path) -> bool {
if let Some(parent) = exe_path.parent() {
let parent_canonical = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
for dir in KNOWN_RELEASE_DIRS {
let known = Path::new(dir);
let known_canonical = known.canonicalize().unwrap_or_else(|_| known.to_path_buf());
if parent_canonical == known_canonical {
return true;
}
}
}
false
}
fn is_path_under_cargo_bin(exe_path: &Path) -> bool {
let exe_canonical = exe_path
.canonicalize()
.unwrap_or_else(|_| exe_path.to_path_buf());
if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
let cargo_bin = PathBuf::from(&cargo_home).join("bin");
if let Ok(cargo_bin) = cargo_bin.canonicalize() {
if exe_canonical.starts_with(&cargo_bin) {
return true;
}
}
}
if let Ok(home) = std::env::var("HOME") {
let default_cargo_bin = PathBuf::from(&home).join(".cargo").join("bin");
if let Ok(default_cargo_bin) = default_cargo_bin.canonicalize() {
if exe_canonical.starts_with(&default_cargo_bin) {
return true;
}
}
}
false
}
fn is_path_under_mise(exe_path: &Path) -> bool {
let exe_canonical = exe_path
.canonicalize()
.unwrap_or_else(|_| exe_path.to_path_buf());
if let Ok(mise_data) = std::env::var("MISE_DATA_DIR") {
let mise_installs = PathBuf::from(&mise_data).join("installs");
if let Ok(mise_installs) = mise_installs.canonicalize() {
if exe_canonical.starts_with(&mise_installs) {
return true;
}
}
}
if let Ok(home) = std::env::var("HOME") {
let default_mise = PathBuf::from(&home)
.join(".local")
.join("share")
.join("mise")
.join("installs");
if let Ok(default_mise) = default_mise.canonicalize() {
if exe_canonical.starts_with(&default_mise) {
return true;
}
}
}
false
}
fn update_via_cargo(version: &str) -> Result<()> {
println!("Detected cargo installation. Running cargo install...");
println!();
let version_spec = format!("octorus@{}", version);
let status = Command::new("cargo")
.args(["install", &version_spec])
.status()
.context("Failed to run `cargo install`. Is cargo available?")?;
if !status.success() {
bail!(
"`cargo install {}` failed with exit code: {}",
version_spec,
status
);
}
Ok(())
}
fn detect_target() -> Result<&'static str> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return Ok("aarch64-apple-darwin");
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
return Ok("x86_64-apple-darwin");
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
return Ok("x86_64-unknown-linux-gnu");
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
return Ok("aarch64-unknown-linux-gnu");
}
#[allow(unreachable_code)]
{
bail!(
"Unsupported platform (os={}, arch={}). Please build from source or use `cargo install octorus`.",
std::env::consts::OS,
std::env::consts::ARCH,
)
}
}
fn get_latest_tag() -> Result<String> {
let output = Command::new("gh")
.args([
"api",
&format!("repos/{}/releases/latest", REPO),
"-q",
".tag_name",
])
.output()
.context("Failed to execute gh CLI - is it installed and authenticated?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to fetch latest release: {}", stderr.trim());
}
let tag = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in gh output")?
.trim()
.to_string();
if tag.is_empty() {
bail!("No releases found for {}", REPO);
}
Ok(tag)
}
fn download_asset(tag: &str, pattern: &str, dest_dir: &Path) -> Result<()> {
let output = Command::new("gh")
.args([
"release",
"download",
tag,
"--repo",
REPO,
"--pattern",
pattern,
"--dir",
&dest_dir.to_string_lossy(),
])
.output()
.context("Failed to download release asset")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to download {}: {}", pattern, stderr.trim());
}
Ok(())
}
fn extract_archive(dir: &Path, archive_name: &str) -> Result<()> {
let archive_path = dir.join(archive_name);
let output = Command::new("tar")
.args(["xzf", &archive_path.to_string_lossy()])
.current_dir(dir)
.output()
.context("Failed to run tar")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to extract archive: {}", stderr.trim());
}
Ok(())
}
fn verify_checksum(dir: &Path, archive_name: &str) -> Result<()> {
let sha256_file = dir.join(format!("{}.sha256", archive_name));
let expected = fs::read_to_string(&sha256_file)
.context("Failed to read checksum file")?
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if expected.is_empty() {
bail!("Checksum file is empty or malformed");
}
let archive_path = dir.join(archive_name);
let output = Command::new("shasum")
.args(["-a", "256", &archive_path.to_string_lossy()])
.output()
.or_else(|_| {
Command::new("sha256sum")
.arg(archive_path.to_string_lossy().to_string())
.output()
})
.context("Failed to compute checksum (shasum/sha256sum not found)")?;
if !output.status.success() {
bail!("Failed to compute SHA256 checksum");
}
let actual = String::from_utf8(output.stdout)
.context("Invalid checksum output")?
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if actual != expected {
bail!(
"Checksum mismatch!\n Expected: {}\n Actual: {}",
expected,
actual
);
}
Ok(())
}
fn replace_binary(new_binary: &Path, current_exe: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(new_binary, perms).context("Failed to set executable permissions")?;
}
let backup_path = current_exe.with_extension("old");
fs::rename(current_exe, &backup_path).with_context(|| {
format!(
"Failed to backup current binary at {}. You may need elevated permissions.",
current_exe.display()
)
})?;
match fs::copy(new_binary, current_exe) {
Ok(_) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
let _ = fs::set_permissions(current_exe, perms);
}
let _ = fs::remove_file(&backup_path);
Ok(())
}
Err(e) => {
let _ = fs::rename(&backup_path, current_exe);
Err(e).context("Failed to install new binary. Original binary has been restored.")
}
}
}
fn update_via_release(latest_tag: &str, latest_version: &str) -> Result<()> {
let target = detect_target()?;
let archive_name = format!("octorus-{}-{}.tar.gz", latest_version, target);
let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
let temp_path = temp_dir.path();
println!("Downloading {}...", archive_name);
download_asset(latest_tag, &archive_name, temp_path)?;
download_asset(latest_tag, &format!("{}.sha256", archive_name), temp_path)?;
println!("Verifying checksum...");
verify_checksum(temp_path, &archive_name)?;
extract_archive(temp_path, &archive_name)?;
let extracted_dir = format!("octorus-{}-{}", latest_version, target);
let new_binary = temp_path.join(&extracted_dir).join("or");
if !new_binary.exists() {
bail!(
"Binary not found in archive at expected path: {}/or",
extracted_dir
);
}
let current_exe =
std::env::current_exe().context("Failed to determine current executable path")?;
let current_exe = current_exe
.canonicalize()
.context("Failed to resolve current executable path")?;
println!("Installing to {}...", current_exe.display());
replace_binary(&new_binary, ¤t_exe)?;
Ok(())
}
pub fn check_for_update() -> Option<String> {
let current_version = env!("CARGO_PKG_VERSION");
let tag = get_latest_tag().ok()?;
let latest = tag.strip_prefix('v').unwrap_or(&tag);
if is_newer_version(current_version, latest) {
Some(latest.to_string())
} else {
None
}
}
pub fn run_update() -> Result<UpdateResult> {
let current_version = env!("CARGO_PKG_VERSION");
println!("Checking for updates...");
let latest_tag = get_latest_tag()?;
let latest_version = latest_tag.strip_prefix('v').unwrap_or(&latest_tag);
if !is_newer_version(current_version, latest_version) {
println!("Already up to date (v{})", current_version);
return Ok(UpdateResult::AlreadyCurrent);
}
println!("Updating: v{} → v{}", current_version, latest_version);
let current_exe =
std::env::current_exe().context("Failed to determine current executable path")?;
let current_exe = current_exe
.canonicalize()
.context("Failed to resolve current executable path")?;
match detect_install_method(¤t_exe) {
InstallMethod::CargoInstall => {
update_via_cargo(latest_version)?;
}
InstallMethod::Mise => {
let message = format!(
"Detected mise-managed installation at:\n {}\n\nPlease update via mise:\n mise install octorus@{}\n mise use octorus@{}",
current_exe.display(), latest_version, latest_version
);
println!("{}", message);
return Ok(UpdateResult::ManualActionRequired { message });
}
InstallMethod::GitHubRelease => {
update_via_release(&latest_tag, latest_version)?;
}
InstallMethod::Unknown => {
let message = format!(
"Could not determine how octorus was installed.\n Executable path: {}\n\nPlease update using the same method you used to install octorus,\nor download the latest release manually:\n gh release download {} --repo {}",
current_exe.display(), latest_tag, REPO
);
println!("{}", message);
return Ok(UpdateResult::ManualActionRequired { message });
}
}
println!("Successfully updated to v{}", latest_version);
println!();
println!("Run `or migrate` to update configuration files and prompts.");
Ok(UpdateResult::Updated {
version: latest_version.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_semver_valid() {
assert_eq!(parse_semver("0.5.6"), Some((0, 5, 6)));
assert_eq!(parse_semver("1.0.0"), Some((1, 0, 0)));
assert_eq!(parse_semver("12.34.56"), Some((12, 34, 56)));
}
#[test]
fn test_parse_semver_invalid() {
assert_eq!(parse_semver("0.5"), None);
assert_eq!(parse_semver("0.5.6.7"), None);
assert_eq!(parse_semver("abc"), None);
assert_eq!(parse_semver("0.5.beta"), None);
}
#[test]
fn test_is_newer_version() {
assert!(is_newer_version("0.5.6", "0.5.7"));
assert!(is_newer_version("0.5.6", "0.6.0"));
assert!(is_newer_version("0.5.6", "1.0.0"));
assert!(!is_newer_version("0.5.6", "0.5.6"));
assert!(!is_newer_version("0.5.7", "0.5.6"));
assert!(!is_newer_version("1.0.0", "0.9.9"));
assert!(!is_newer_version("0.5.6", "invalid"));
assert!(!is_newer_version("invalid", "0.5.7"));
}
#[test]
fn test_detect_target_returns_valid_triple() {
let result = detect_target();
assert!(result.is_ok());
let target = result.unwrap();
let valid_targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
];
assert!(
valid_targets.contains(&target),
"Unexpected target: {}",
target
);
}
#[test]
fn test_replace_binary_success() {
let temp_dir = TempDir::new().unwrap();
let old_path = temp_dir.path().join("or");
let new_path = temp_dir.path().join("or_new");
fs::write(&old_path, b"old binary").unwrap();
fs::write(&new_path, b"new binary").unwrap();
replace_binary(&new_path, &old_path).unwrap();
let content = fs::read(&old_path).unwrap();
assert_eq!(content, b"new binary");
assert!(!temp_dir.path().join("or.old").exists());
}
#[test]
fn test_replace_binary_restores_on_failure() {
let temp_dir = TempDir::new().unwrap();
let old_path = temp_dir.path().join("or");
let new_path = temp_dir.path().join("nonexistent");
fs::write(&old_path, b"old binary").unwrap();
let result = replace_binary(&new_path, &old_path);
assert!(result.is_err());
let content = fs::read(&old_path).unwrap();
assert_eq!(content, b"old binary");
}
#[test]
fn test_verify_checksum_valid() {
let temp_dir = TempDir::new().unwrap();
let archive_name = "test.tar.gz";
let archive_path = temp_dir.path().join(archive_name);
let checksum_path = temp_dir.path().join(format!("{}.sha256", archive_name));
fs::write(&archive_path, b"test content").unwrap();
let output = Command::new("shasum")
.args(["-a", "256", &archive_path.to_string_lossy()])
.output()
.or_else(|_| {
Command::new("sha256sum")
.arg(archive_path.to_string_lossy().to_string())
.output()
})
.unwrap();
let checksum_line = String::from_utf8(output.stdout).unwrap();
fs::write(&checksum_path, &checksum_line).unwrap();
let result = verify_checksum(temp_dir.path(), archive_name);
assert!(result.is_ok(), "Checksum verification failed: {:?}", result);
}
#[test]
fn test_detect_cargo_install() {
if let Ok(home) = std::env::var("HOME") {
let cargo_bin = PathBuf::from(&home).join(".cargo").join("bin");
if cargo_bin.exists() {
let fake_exe = cargo_bin.join("or");
assert!(is_path_under_cargo_bin(&fake_exe));
assert_eq!(
detect_install_method(&fake_exe),
InstallMethod::CargoInstall
);
}
}
}
#[test]
fn test_detect_mise_install() {
if let Ok(home) = std::env::var("HOME") {
let mise_dir = PathBuf::from(&home)
.join(".local")
.join("share")
.join("mise")
.join("installs");
if mise_dir.exists() {
let fake_exe = mise_dir
.join("octorus")
.join("0.5.6")
.join("bin")
.join("or");
assert!(is_path_under_mise(&fake_exe));
assert_eq!(detect_install_method(&fake_exe), InstallMethod::Mise);
}
}
}
#[test]
fn test_detect_github_release_for_known_locations() {
let path = Path::new("/usr/local/bin/or");
if path.parent().is_some_and(|p| p.exists()) {
assert_eq!(detect_install_method(path), InstallMethod::GitHubRelease);
}
}
#[test]
fn test_detect_unknown_for_unrecognized_paths() {
let path = Path::new("/some/random/location/or");
assert_eq!(detect_install_method(path), InstallMethod::Unknown);
}
#[test]
fn test_verify_checksum_mismatch() {
let temp_dir = TempDir::new().unwrap();
let archive_name = "test.tar.gz";
let archive_path = temp_dir.path().join(archive_name);
let checksum_path = temp_dir.path().join(format!("{}.sha256", archive_name));
fs::write(&archive_path, b"test content").unwrap();
fs::write(
&checksum_path,
"0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n",
)
.unwrap();
let result = verify_checksum(temp_dir.path(), archive_name);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Checksum mismatch"),
"Should report checksum mismatch"
);
}
}