use std::env::consts::EXE_EXTENSION;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use itertools::Itertools;
use prek_consts::env_vars::EnvVars;
use serde::Deserialize;
use target_lexicon::{Architecture, Environment, HOST, OperatingSystem, Triple};
use tracing::{debug, trace, warn};
use crate::fs::LockedFile;
use crate::http::{REQWEST_CLIENT, download_and_extract_with};
use crate::languages::ruby::RubyRequest;
use crate::process::Cmd;
use crate::store::Store;
const RV_RUBY_DEFAULT_URL: &str = "https://github.com/spinel-coop/rv-ruby";
fn rv_ruby_mirror() -> (String, bool) {
match EnvVars::var(EnvVars::PREK_RUBY_MIRROR) {
Ok(mirror) => {
let is_github = is_github_https(&mirror);
(mirror, is_github)
}
Err(_) => (RV_RUBY_DEFAULT_URL.to_string(), true),
}
}
fn rv_ruby_api_url() -> (String, bool) {
let (base, is_github) = rv_ruby_mirror();
let url = if is_github {
let path = base
.strip_prefix("https://github.com")
.expect("is_github_https should ensure this");
format!("https://api.github.com/repos{path}/releases/latest")
} else {
format!("{base}/releases/latest")
};
(url, is_github)
}
fn is_github_https(url: &str) -> bool {
(url.starts_with("https://github.com/") || url.starts_with("https://github.com:"))
&& !url.contains('@')
}
fn rv_ruby_download_base() -> (String, bool) {
let (base, is_github) = rv_ruby_mirror();
(format!("{base}/releases/latest/download"), is_github)
}
fn maybe_add_github_auth(req: reqwest::RequestBuilder, is_github: bool) -> reqwest::RequestBuilder {
if is_github {
if let Ok(token) = EnvVars::var(EnvVars::GITHUB_TOKEN) {
return req.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
}
req
}
#[derive(Deserialize)]
struct GitHubRelease {
assets: Vec<GitHubAsset>,
}
#[derive(Deserialize)]
struct GitHubAsset {
name: String,
}
fn rv_platform_string(triple: &Triple) -> Option<&'static str> {
match (
triple.operating_system,
triple.architecture,
triple.environment,
) {
(OperatingSystem::Darwin(_), Architecture::X86_64, _) => Some("ventura"),
(OperatingSystem::Darwin(_), Architecture::Aarch64(_), _) => Some("arm64_sonoma"),
(OperatingSystem::Linux, Architecture::X86_64, Environment::Gnu) => Some("x86_64_linux"),
(OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Gnu) => Some("arm64_linux"),
(OperatingSystem::Linux, Architecture::X86_64, Environment::Musl) => {
Some("x86_64_linux_musl")
}
(OperatingSystem::Linux, Architecture::Aarch64(_), Environment::Musl) => {
Some("arm64_linux_musl")
}
_ => None,
}
}
#[derive(Debug)]
pub(crate) struct RubyResult {
ruby_bin: PathBuf,
version: semver::Version,
engine: String,
}
impl RubyResult {
pub(crate) fn ruby_bin(&self) -> &Path {
&self.ruby_bin
}
pub(crate) fn version(&self) -> &semver::Version {
&self.version
}
pub(crate) fn engine(&self) -> &str {
&self.engine
}
}
pub(crate) struct RubyInstaller {
root: PathBuf,
}
impl RubyInstaller {
pub(crate) fn new(root: PathBuf) -> Self {
Self { root }
}
pub(crate) async fn install(
&self,
store: &Store,
request: &RubyRequest,
allows_download: bool,
) -> Result<RubyResult> {
fs_err::tokio::create_dir_all(&self.root).await?;
let _lock = LockedFile::acquire(self.root.join(".lock"), "ruby").await?;
if let Some(ruby) = self.find_installed(request) {
trace!(
"Using managed Ruby: {} at {}",
ruby.version(),
ruby.ruby_bin().display()
);
return Ok(ruby);
}
if let Some(ruby) = self.find_system_ruby(request).await? {
trace!(
"Using system Ruby: {} at {}",
ruby.version(),
ruby.ruby_bin().display()
);
return Ok(ruby);
}
if !allows_download {
anyhow::bail!(ruby_not_found_error(
request,
"Automatic installation is disabled (language_version: system)."
));
}
let Some(platform) = rv_platform_string(&HOST) else {
anyhow::bail!(ruby_not_found_error(
request,
"Automatic installation is not supported on this platform."
));
};
let versions = match self.list_remote_versions(platform).await {
Ok(v) => v,
Err(e) => {
anyhow::bail!(
"{}\n\nCaused by:\n {e}",
ruby_not_found_error(
request,
"Failed to fetch available Ruby versions from rv-ruby."
)
);
}
};
let Some(version) = versions.into_iter().find(|v| request.matches(v, None)) else {
anyhow::bail!(ruby_not_found_error(
request,
&format!("No rv-ruby release found matching: {request}")
));
};
self.download(store, &version, platform).await
}
fn find_installed(&self, request: &RubyRequest) -> Option<RubyResult> {
fs_err::read_dir(&self.root)
.ok()?
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))
.filter_map(|entry| {
let version = semver::Version::parse(&entry.file_name().to_string_lossy()).ok()?;
let bin_dir = entry.path().join("bin");
let ruby_bin = bin_dir.join("ruby");
let gem_bin = bin_dir.join("gem");
if ruby_bin.exists() && gem_bin.exists() {
Some((version, ruby_bin))
} else {
None
}
})
.sorted_unstable_by(|(a, _), (b, _)| b.cmp(a)) .find_map(|(version, ruby_bin)| {
if request.matches(&version, Some(&ruby_bin)) {
Some(RubyResult {
ruby_bin,
version,
engine: "ruby".to_string(),
})
} else {
None
}
})
}
async fn list_remote_versions(&self, platform: &str) -> Result<Vec<semver::Version>> {
let (api_url, is_github) = rv_ruby_api_url();
let suffix = format!(".{platform}.tar.gz");
let req = REQWEST_CLIENT
.get(&api_url)
.header("Accept", "application/vnd.github+json");
let req = maybe_add_github_auth(req, is_github);
let response = req
.send()
.await
.with_context(|| format!("Failed to fetch rv-ruby releases from {api_url}"))?;
if !response.status().is_success() {
let status = response.status();
let hint = if matches!(status.as_u16(), 403 | 429) {
" (this may be a rate limit — try setting GITHUB_TOKEN)"
} else {
""
};
anyhow::bail!("Failed to fetch rv-ruby releases from {api_url}: {status}{hint}");
}
let release: GitHubRelease = response
.json()
.await
.context("Failed to parse rv-ruby release JSON")?;
let versions = release
.assets
.iter()
.filter_map(|asset| parse_version_from_asset(&asset.name, &suffix))
.sorted_unstable()
.rev()
.collect();
Ok(versions)
}
async fn download(
&self,
store: &Store,
version: &semver::Version,
platform: &str,
) -> Result<RubyResult> {
let filename = format!("ruby-{version}.{platform}.tar.gz");
let (base_url, is_github) = rv_ruby_download_base();
let url = format!("{base_url}/{filename}");
let version_str = version.to_string();
let target = self.root.join(&version_str);
debug!(url = %url, target = %target.display(), "Downloading Ruby {version}");
download_and_extract_with(
&url,
&filename,
store,
|req| maybe_add_github_auth(req, is_github),
async |extracted| {
let inner = extracted.join(&version_str);
if !inner.exists() {
anyhow::bail!(
"Expected directory '{}' inside rv-ruby archive, found: {:?}",
version_str,
fs_err::read_dir(extracted)?
.flatten()
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
}
if target.exists() {
debug!(target = %target.display(), "Removing existing Ruby");
fs_err::tokio::remove_dir_all(&target).await?;
}
fs_err::tokio::rename(&inner, &target).await?;
Ok(())
},
)
.await
.with_context(|| format!("Failed to download Ruby {version} from {url}"))?;
Ok(RubyResult {
ruby_bin: target.join("bin").join("ruby"),
version: version.clone(),
engine: "ruby".to_string(),
})
}
async fn find_system_ruby(&self, request: &RubyRequest) -> Result<Option<RubyResult>> {
if let Ok(ruby_paths) = which::which_all("ruby") {
for ruby_path in ruby_paths {
if let Some(result) = try_ruby_path(&ruby_path, request).await {
return Ok(Some(result));
}
}
}
#[cfg(not(target_os = "windows"))]
if let Some(result) = search_version_managers(request).await {
return Ok(Some(result));
}
Ok(None)
}
}
async fn try_ruby_path(ruby_path: &Path, request: &RubyRequest) -> Option<RubyResult> {
if let Err(e) = find_gem_for_ruby(ruby_path) {
warn!("Ruby at {} has no gem: {}", ruby_path.display(), e);
return None;
}
match query_ruby_info(ruby_path).await {
Ok((version, engine)) => {
let result = RubyResult {
ruby_bin: ruby_path.to_path_buf(),
version,
engine,
};
if request.matches(&result.version, Some(&result.ruby_bin)) {
Some(result)
} else {
None
}
}
Err(e) => {
warn!("Failed to query Ruby at {}: {}", ruby_path.display(), e);
None
}
}
}
#[cfg(not(target_os = "windows"))]
async fn search_version_managers(request: &RubyRequest) -> Option<RubyResult> {
let home = EnvVars::var(EnvVars::HOME).ok()?;
let home_path = PathBuf::from(home);
let search_dirs = [
home_path.join(".rvm/rubies"),
home_path.join(".local/share/rv/rubies"),
home_path.join(".data/rv/rubies"),
home_path.join(".local/share/mise/installs/ruby"),
home_path.join(".rbenv/versions"),
home_path.join(".asdf/installs/ruby"),
home_path.join(".rubies"),
PathBuf::from("/opt/rubies"),
PathBuf::from("/opt/homebrew/Cellar/ruby"),
PathBuf::from("/usr/local/Cellar/ruby"),
PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/ruby"),
home_path.join(".linuxbrew/Cellar/ruby"),
];
for search_dir in &search_dirs {
if let Some(result) = search_ruby_installations(search_dir, request).await {
return Some(result);
}
}
None
}
#[cfg(not(target_os = "windows"))]
async fn search_ruby_installations(dir: &Path, request: &RubyRequest) -> Option<RubyResult> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let ruby_path = path.join("bin/ruby");
if ruby_path.exists() {
if let Some(result) = try_ruby_path(&ruby_path, request).await {
trace!(
"Found suitable Ruby in version manager: {}",
ruby_path.display()
);
return Some(result);
}
}
}
None
}
fn parse_version_from_asset(name: &str, platform_suffix: &str) -> Option<semver::Version> {
let name = name.strip_prefix("ruby-")?;
let version_str = name.strip_suffix(platform_suffix)?;
let version = semver::Version::parse(version_str).ok()?;
if !version.pre.is_empty() {
return None;
}
Some(version)
}
fn ruby_not_found_error(request: &RubyRequest, reason: &str) -> String {
format!(
"No suitable Ruby found for request: {request}\n{reason}\nPlease install Ruby manually."
)
}
fn find_gem_for_ruby(ruby_path: &Path) -> Result<PathBuf> {
let ruby_dir = ruby_path
.parent()
.context("Ruby executable has no parent directory")?;
for name in ["gem", "gem.bat", "gem.cmd"] {
let gem_path = ruby_dir.join(name).with_extension(EXE_EXTENSION);
if gem_path.exists() {
return Ok(gem_path);
}
let gem_path = ruby_dir.join(name);
if gem_path.exists() {
return Ok(gem_path);
}
}
anyhow::bail!(
"No gem executable found alongside Ruby at {}",
ruby_path.display()
)
}
pub(crate) async fn query_ruby_info(ruby_path: &Path) -> Result<(semver::Version, String)> {
let script = "puts RUBY_ENGINE; puts RUBY_VERSION";
let output = Cmd::new(ruby_path, "query ruby version")
.arg("-e")
.arg(script)
.check(true)
.output()
.await?;
let mut lines = str::from_utf8(&output.stdout)?.lines();
let engine = lines.next().unwrap_or("ruby").to_string();
let version_str = lines.next().context("No version in Ruby output")?.trim();
let version = semver::Version::parse(version_str)
.with_context(|| format!("Failed to parse Ruby version: {version_str}"))?;
Ok((version, engine))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::str::FromStr;
use target_lexicon::Triple;
use tempfile::TempDir;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvVarGuard {
key: &'static str,
saved: Option<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvVarGuard {
fn new(key: &'static str) -> Self {
let lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let saved = EnvVars::var(key).ok();
Self {
key,
saved,
_lock: lock,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.saved {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn test_ruby_request_display() {
assert_eq!(RubyRequest::Any.to_string(), "any");
assert_eq!(RubyRequest::Exact(3, 4, 6).to_string(), "3.4.6");
assert_eq!(RubyRequest::MajorMinor(3, 4).to_string(), "3.4");
assert_eq!(RubyRequest::Major(3).to_string(), "3");
let range = semver::VersionReq::parse(">=3.2").unwrap();
assert_eq!(
RubyRequest::Range(range, ">=3.2".to_string()).to_string(),
">=3.2"
);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_no_ruby() {
let temp_dir = TempDir::new().unwrap();
let ruby_dir = temp_dir.path().join("ruby-3.4.6");
fs::create_dir_all(ruby_dir.join("bin")).unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_search_ruby_installations_with_file() {
let temp_dir = TempDir::new().unwrap();
let ruby_dir = temp_dir.path().join("ruby-3.4.6");
fs::create_dir_all(ruby_dir.join("bin")).unwrap();
let ruby_path = ruby_dir.join("bin/ruby");
fs::write(&ruby_path, "#!/bin/sh\necho fake ruby").unwrap();
let request = RubyRequest::Any;
let result = search_ruby_installations(temp_dir.path(), &request).await;
assert!(result.is_none());
}
#[test]
fn test_ruby_not_found_error() {
let error = ruby_not_found_error(&RubyRequest::Exact(3, 4, 6), "Some reason.");
assert!(error.contains("3.4.6"));
assert!(error.contains("No suitable Ruby found"));
assert!(error.contains("Some reason."));
assert!(error.contains("Please install Ruby manually."));
let error = ruby_not_found_error(&RubyRequest::Any, "Another reason.");
assert!(error.contains("any"));
assert!(error.contains("Another reason."));
}
#[test]
fn test_rv_ruby_urls_default() {
let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);
unsafe { std::env::remove_var(EnvVars::PREK_RUBY_MIRROR) };
let (api_url, api_is_github) = rv_ruby_api_url();
assert_eq!(
api_url,
"https://api.github.com/repos/spinel-coop/rv-ruby/releases/latest"
);
assert!(api_is_github);
let (dl_url, dl_is_github) = rv_ruby_download_base();
assert_eq!(
dl_url,
format!("{RV_RUBY_DEFAULT_URL}/releases/latest/download")
);
assert!(dl_is_github);
}
#[test]
fn test_rv_ruby_urls_github_mirror() {
let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);
unsafe {
std::env::set_var(
EnvVars::PREK_RUBY_MIRROR,
"https://github.com/myorg/vetted-rubies",
);
}
let (api_url, api_is_github) = rv_ruby_api_url();
assert_eq!(
api_url,
"https://api.github.com/repos/myorg/vetted-rubies/releases/latest"
);
assert!(api_is_github);
let (dl_url, dl_is_github) = rv_ruby_download_base();
assert_eq!(
dl_url,
"https://github.com/myorg/vetted-rubies/releases/latest/download"
);
assert!(dl_is_github);
}
#[test]
fn test_rv_ruby_urls_non_github_mirror() {
let _guard = EnvVarGuard::new(EnvVars::PREK_RUBY_MIRROR);
unsafe {
std::env::set_var(
EnvVars::PREK_RUBY_MIRROR,
"https://my-mirror.example.com/rv-ruby",
);
}
let (api_url, api_is_github) = rv_ruby_api_url();
assert_eq!(
api_url,
"https://my-mirror.example.com/rv-ruby/releases/latest"
);
assert!(!api_is_github);
let (dl_url, dl_is_github) = rv_ruby_download_base();
assert_eq!(
dl_url,
"https://my-mirror.example.com/rv-ruby/releases/latest/download"
);
assert!(!dl_is_github);
}
#[test]
fn test_find_gem_for_ruby_missing() {
let temp_dir = TempDir::new().unwrap();
let ruby_path = temp_dir.path().join("bin/ruby");
fs::create_dir_all(temp_dir.path().join("bin")).unwrap();
fs::write(&ruby_path, "fake").unwrap();
let result = find_gem_for_ruby(&ruby_path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No gem executable found")
);
}
#[test]
fn test_find_gem_for_ruby_found() {
let temp_dir = TempDir::new().unwrap();
let bin_dir = temp_dir.path().join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let ruby_path = bin_dir.join("ruby");
let gem_path = bin_dir.join("gem");
fs::write(&ruby_path, "fake ruby").unwrap();
fs::write(&gem_path, "fake gem").unwrap();
let result = find_gem_for_ruby(&ruby_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), gem_path);
}
#[test]
fn test_parse_version_from_asset() {
let suffix = ".x86_64_linux.tar.gz";
assert_eq!(
parse_version_from_asset("ruby-3.4.8.x86_64_linux.tar.gz", suffix),
Some(semver::Version::new(3, 4, 8))
);
assert_eq!(
parse_version_from_asset("ruby-3.3.0.x86_64_linux.tar.gz", suffix),
Some(semver::Version::new(3, 3, 0))
);
assert_eq!(
parse_version_from_asset("ruby-3.4.8.arm64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("ruby-3.5.0-preview1.x86_64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("ruby-0.49.x86_64_linux.tar.gz", suffix),
None
);
assert_eq!(
parse_version_from_asset("something-else.tar.gz", suffix),
None
);
}
#[test]
fn test_rv_platform_string_for_macos() {
let intel = Triple::from_str("x86_64-apple-darwin").unwrap();
assert_eq!(rv_platform_string(&intel), Some("ventura"));
let arm = Triple::from_str("aarch64-apple-darwin").unwrap();
assert_eq!(rv_platform_string(&arm), Some("arm64_sonoma"));
}
#[test]
fn test_rv_platform_string_for_linux() {
let gnu = Triple::from_str("x86_64-unknown-linux-gnu").unwrap();
assert_eq!(rv_platform_string(&gnu), Some("x86_64_linux"));
let arm_gnu = Triple::from_str("aarch64-unknown-linux-gnu").unwrap();
assert_eq!(rv_platform_string(&arm_gnu), Some("arm64_linux"));
let musl = Triple::from_str("x86_64-unknown-linux-musl").unwrap();
assert_eq!(rv_platform_string(&musl), Some("x86_64_linux_musl"));
let arm_musl = Triple::from_str("aarch64-unknown-linux-musl").unwrap();
assert_eq!(rv_platform_string(&arm_musl,), Some("arm64_linux_musl"));
}
#[test]
fn test_rv_platform_string_unsupported() {
let windows = Triple::from_str("x86_64-pc-windows-msvc").unwrap();
assert_eq!(rv_platform_string(&windows), None);
let linux_unknown_libc = Triple::from_str("x86_64-unknown-linux-gnux32").unwrap();
assert_eq!(rv_platform_string(&linux_unknown_libc), None);
}
#[test]
fn test_find_installed_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
assert!(installer.find_installed(&RubyRequest::Any).is_none());
}
#[test]
fn test_find_installed_with_versions() {
let temp_dir = TempDir::new().unwrap();
for version in ["3.3.5", "3.4.8", "3.2.1"] {
let bin_dir = temp_dir.path().join(version).join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("ruby"), "fake").unwrap();
fs::write(bin_dir.join("gem"), "fake").unwrap();
}
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
let result = installer.find_installed(&RubyRequest::Any).unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 4, 8));
let result = installer
.find_installed(&RubyRequest::MajorMinor(3, 3))
.unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 3, 5));
let result = installer
.find_installed(&RubyRequest::Exact(3, 2, 1))
.unwrap();
assert_eq!(*result.version(), semver::Version::new(3, 2, 1));
assert!(
installer
.find_installed(&RubyRequest::MajorMinor(2, 7))
.is_none()
);
}
#[test]
fn test_is_github_https() {
assert!(is_github_https("https://github.com/spinel-coop/rv-ruby"));
assert!(is_github_https("https://github.com:443/org/repo"));
assert!(!is_github_https("http://github.com/org/repo"));
assert!(!is_github_https("https://gitlab.com/org/repo"));
assert!(!is_github_https("https://my-mirror.example.com/rv-ruby"));
assert!(!is_github_https("https://evil.com/github.com/rv"));
assert!(!is_github_https("https://api.github.com/repos/org/repo"));
assert!(!is_github_https("https://github.com@evil.com/org/repo"));
assert!(!is_github_https(
"https://github.com:password@evil.com/org/repo"
));
assert!(!is_github_https("https://evil.com@github.com/org/repo"));
assert!(!is_github_https("ftp://github.com/org/repo"));
}
#[test]
fn test_find_installed_skips_incomplete_dirs() {
let temp_dir = TempDir::new().unwrap();
let bin_dir = temp_dir.path().join("3.4.8").join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("ruby"), "fake").unwrap();
fs::create_dir_all(temp_dir.path().join("3.3.0")).unwrap();
fs::create_dir_all(temp_dir.path().join("not-a-version").join("bin")).unwrap();
let installer = RubyInstaller::new(temp_dir.path().to_path_buf());
assert!(installer.find_installed(&RubyRequest::Any).is_none());
}
}