#[cfg(not(windows))]
use std::process::Stdio;
use std::{
path::{Path, PathBuf},
time::Duration,
};
#[cfg(not(windows))]
use anyhow::Context;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
use serde::Deserialize;
#[cfg(not(windows))]
use tokio::process::Command;
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/matthewyjiang/rho/releases/latest";
const CRATE_NAME: &str = "rho-coding-agent";
const PACMAN_PACKAGE: &str = "rho-coding-agent";
#[cfg(not(windows))]
const SCRIPT_INSTALL_SH_COMMAND: &str = "tmp=$(mktemp) || exit; curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/matthewyjiang/rho/main/scripts/install.sh -o \"$tmp\"; status=$?; if [ $status -eq 0 ]; then sh \"$tmp\"; status=$?; fi; rm -f \"$tmp\"; exit $status";
#[cfg(windows)]
const SCRIPT_INSTALL_PS1_COMMAND: &str =
"irm https://raw.githubusercontent.com/matthewyjiang/rho/main/scripts/install.ps1 | iex";
#[cfg(windows)]
const SCRIPT_INSTALL_PS1_DISPLAY_COMMAND: &str = "powershell -NoProfile -ExecutionPolicy Bypass -Command \"irm https://raw.githubusercontent.com/matthewyjiang/rho/main/scripts/install.ps1 | iex\"";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateInfo {
pub latest_tag: String,
pub latest_version: String,
pub current_version: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InstallMethod {
Cargo,
Pacman,
Script,
}
impl InstallMethod {
pub fn label(self) -> &'static str {
match self {
Self::Cargo => "Cargo",
Self::Pacman => "pacman",
Self::Script => "install script",
}
}
pub fn update_command(self) -> String {
match self {
Self::Cargo => cargo_update_command_display(),
Self::Pacman => pacman_update_command_display(),
Self::Script => script_update_command_display(),
}
}
}
#[derive(Deserialize)]
struct LatestRelease {
tag_name: String,
}
pub async fn available_update(current_version: &str) -> anyhow::Result<Option<UpdateInfo>> {
let latest_tag = latest_release_tag().await?;
let Some(latest_version) = release_tag_to_version(&latest_tag) else {
anyhow::bail!("latest release tag '{latest_tag}' does not contain a version");
};
if version_is_newer(&latest_version, current_version) {
Ok(Some(UpdateInfo {
latest_tag,
latest_version,
current_version: current_version.to_string(),
}))
} else {
Ok(None)
}
}
pub async fn update_notice(current_version: &str) -> Option<String> {
match tokio::time::timeout(
Duration::from_millis(900),
available_update(current_version),
)
.await
{
Ok(Ok(Some(update))) => Some(format!(
"update available: v{} (current v{}). run `rho update` to {} via {}.",
update.latest_version,
update.current_version,
update_action_label(),
detect_install_method().label()
)),
Ok(Ok(None)) | Ok(Err(_)) | Err(_) => None,
}
}
pub async fn run_update(current_version: &str) -> anyhow::Result<()> {
let method = detect_install_method();
println!("detected install method: {}", method.label());
match available_update(current_version).await {
Ok(Some(update)) => {
println!(
"rho v{} is available (current v{}).",
update.latest_version, update.current_version
);
}
Ok(None) => {
println!("rho is up to date (v{current_version}).");
return Ok(());
}
Err(err) => {
eprintln!("warning: could not check latest release: {err}");
println!("continuing with {} update command.", method.label());
}
}
println!("update command: {}", method.update_command());
if method == InstallMethod::Pacman {
println!("pacman may prompt for your sudo password.");
}
run_update_command(method).await
}
async fn run_update_command(method: InstallMethod) -> anyhow::Result<()> {
#[cfg(windows)]
{
println!(
"automatic updates are disabled on Windows to avoid launching background shells that can trigger security software."
);
println!("copy and run this command yourself to update:");
println!("{}", method.update_command());
return Ok(());
}
#[cfg(not(windows))]
{
let status = update_command(method)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.with_context(|| format!("failed to run {} update command", method.label()))?;
if !status.success() {
anyhow::bail!("{} update command exited with {status}", method.label());
}
Ok(())
}
}
#[cfg(windows)]
fn update_action_label() -> &'static str {
"show the update command"
}
#[cfg(not(windows))]
fn update_action_label() -> &'static str {
"update"
}
#[cfg(not(windows))]
fn update_command(method: InstallMethod) -> Command {
match method {
InstallMethod::Cargo => {
let mut command = Command::new("cargo");
command.args(["install", CRATE_NAME, "--locked"]);
if let Some(root) = current_cargo_update_root() {
command.arg("--root").arg(root);
}
command
}
InstallMethod::Pacman => {
let mut command = Command::new("sudo");
command.args(["pacman", "-Syu", PACMAN_PACKAGE]);
command
}
InstallMethod::Script => script_update_command(),
}
}
fn cargo_update_command_display() -> String {
let mut command = format!("cargo install {CRATE_NAME} --locked");
if let Some(root) = current_cargo_update_root() {
command.push_str(" --root ");
command.push_str(&shell_quote_path(&root));
}
command
}
fn pacman_update_command_display() -> String {
format!("sudo pacman -Syu {PACMAN_PACKAGE}")
}
#[cfg(windows)]
fn script_update_command_display() -> String {
let Some(install_dir) = current_exe_parent() else {
return SCRIPT_INSTALL_PS1_DISPLAY_COMMAND.to_string();
};
format!(
"powershell -NoProfile -ExecutionPolicy Bypass -Command {command}",
command = powershell_quote(&format!(
"$env:RHO_INSTALL_DIR={}; {SCRIPT_INSTALL_PS1_COMMAND}",
powershell_quote_path(&install_dir)
))
)
}
#[cfg(not(windows))]
fn script_update_command_display() -> String {
let command = format!("sh -c {}", shell_quote(SCRIPT_INSTALL_SH_COMMAND));
let Some(install_dir) = current_exe_parent() else {
return command;
};
format!(
"RHO_INSTALL_DIR={} {command}",
shell_quote_path(&install_dir)
)
}
#[cfg(not(windows))]
fn script_update_command() -> Command {
let mut command = Command::new("sh");
command.args(["-c", SCRIPT_INSTALL_SH_COMMAND]);
if let Some(install_dir) = current_exe_parent() {
command.env("RHO_INSTALL_DIR", install_dir);
}
command
}
pub fn detect_install_method() -> InstallMethod {
if let Ok(method) = std::env::var("RHO_INSTALL_METHOD") {
match method.trim().to_ascii_lowercase().as_str() {
"cargo" => return InstallMethod::Cargo,
"pacman" => return InstallMethod::Pacman,
"script" | "install-script" => return InstallMethod::Script,
_ => {}
}
}
let current_exe = std::env::current_exe().ok();
if current_exe
.as_deref()
.is_some_and(|path| is_cargo_bin_path(path) || is_cargo_installed_at_root(path))
{
return InstallMethod::Cargo;
}
if current_exe.as_deref().is_some_and(is_pacman_owned) {
return InstallMethod::Pacman;
}
InstallMethod::Script
}
fn current_exe_parent() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))
}
fn current_cargo_update_root() -> Option<PathBuf> {
let current_exe = std::env::current_exe().ok()?;
cargo_update_root_for_exe(¤t_exe, cargo_install_root_contains_crate)
}
fn cargo_update_root_for_exe(
path: &Path,
cargo_root_contains_crate: impl FnOnce(&Path) -> bool,
) -> Option<PathBuf> {
if is_cargo_bin_path(path) {
return None;
}
let root = cargo_root_from_bin_path(path)?;
cargo_root_contains_crate(&root).then_some(root)
}
fn is_cargo_bin_path(path: &Path) -> bool {
let normalized = path.to_string_lossy().replace('\\', "/");
normalized.contains("/.cargo/bin/")
}
fn cargo_root_from_bin_path(path: &Path) -> Option<PathBuf> {
let bin_dir = path.parent()?;
(bin_dir.file_name()? == "bin").then(|| bin_dir.parent().map(Path::to_path_buf))?
}
fn is_cargo_installed_at_root(path: &Path) -> bool {
cargo_update_root_for_exe(path, cargo_install_root_contains_crate).is_some()
}
fn cargo_install_root_contains_crate(root: &Path) -> bool {
std::process::Command::new("cargo")
.args(["install", "--list", "--root"])
.arg(root)
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|stdout| cargo_install_list_contains_crate(&stdout))
}
fn cargo_install_list_contains_crate(output: &str) -> bool {
output
.lines()
.any(|line| line.split_whitespace().next() == Some(CRATE_NAME))
}
fn shell_quote_path(path: &Path) -> String {
shell_quote(&path.to_string_lossy())
}
fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
if value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-'))
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(windows)]
fn powershell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(windows)]
fn powershell_quote_path(path: &Path) -> String {
powershell_quote(&path.to_string_lossy())
}
#[cfg(target_os = "linux")]
fn is_pacman_owned(path: &Path) -> bool {
std::process::Command::new("pacman")
.arg("-Qqo")
.arg(path)
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|owner| owner.trim().contains("rho"))
}
#[cfg(not(target_os = "linux"))]
fn is_pacman_owned(_path: &Path) -> bool {
false
}
async fn latest_release_tag() -> anyhow::Result<String> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("rho-coding-agent"));
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(10))
.build()?;
let release = client
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<LatestRelease>()
.await?;
Ok(release.tag_name)
}
fn release_tag_to_version(tag: &str) -> Option<String> {
let version = tag
.rsplit_once('v')
.map(|(_, version)| version)
.unwrap_or(tag)
.trim();
parse_version(version)
.is_some()
.then(|| version.to_string())
}
fn version_is_newer(candidate: &str, current: &str) -> bool {
let Some(candidate) = parse_version(candidate) else {
return false;
};
let Some(current) = parse_version(current) else {
return false;
};
candidate > current
}
fn parse_version(version: &str) -> Option<Vec<u64>> {
let core = version
.trim()
.trim_start_matches('v')
.split(['-', '+'])
.next()?;
let parts = core
.split('.')
.map(str::parse::<u64>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
(!parts.is_empty()).then_some(parts)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
cargo_install_list_contains_crate, cargo_root_from_bin_path, cargo_update_root_for_exe,
pacman_update_command_display, release_tag_to_version, version_is_newer, InstallMethod,
};
#[test]
fn extracts_release_please_tag_version() {
assert_eq!(
release_tag_to_version("rho-coding-agent-v0.12.3").as_deref(),
Some("0.12.3")
);
}
#[test]
fn compares_dotted_versions() {
assert!(version_is_newer("0.12.3", "0.12.1"));
assert!(version_is_newer("0.13.0", "0.12.9"));
assert!(!version_is_newer("0.12.1", "0.12.1"));
assert!(!version_is_newer("0.12.0", "0.12.1"));
}
#[test]
fn script_update_command_display_uses_platform_installer() {
let command = InstallMethod::Script.update_command();
#[cfg(windows)]
{
assert!(command.contains("powershell"));
assert!(command.contains("install.ps1"));
assert!(!command.contains("install.sh"));
}
#[cfg(not(windows))]
{
assert!(command.contains("sh -c"));
assert!(command.contains("install.sh"));
assert!(!command.contains("install.ps1"));
}
}
#[cfg(not(windows))]
#[test]
fn script_update_command_display_preserves_curl_failure_status() {
let command = InstallMethod::Script.update_command();
assert!(command.contains("curl"));
assert!(command.contains("--proto"));
assert!(command.contains("-o"));
assert!(command.contains("$tmp"));
assert!(command.contains("exit $status"));
assert!(!command.contains("| sh"));
}
#[test]
fn pacman_update_command_refreshes_sync_database() {
assert_eq!(
pacman_update_command_display(),
"sudo pacman -Syu rho-coding-agent"
);
}
#[test]
fn detects_cargo_root_from_parent_bin_directory() {
let exe = Path::new("/opt/rho/bin/rho");
assert_eq!(
cargo_root_from_bin_path(exe).as_deref(),
Some(Path::new("/opt/rho"))
);
assert_eq!(
cargo_update_root_for_exe(exe, |root| root == Path::new("/opt/rho")).as_deref(),
Some(Path::new("/opt/rho"))
);
assert!(cargo_update_root_for_exe(exe, |_| false).is_none());
assert!(
cargo_update_root_for_exe(Path::new("/home/me/.cargo/bin/rho"), |_| true).is_none()
);
}
#[test]
fn detects_crate_in_cargo_install_list_output() {
let output = "ripgrep v14.1.1:\n rg\nrho-coding-agent v0.12.3:\n rho\n";
assert!(cargo_install_list_contains_crate(output));
assert!(!cargo_install_list_contains_crate(
"rho-helper v0.1.0:\n rho-helper\n"
));
}
}