use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::backend_type::BackendType;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::file;
use crate::http::HTTP_FETCH;
use crate::install_context::InstallContext;
use crate::toolset::ToolVersion;
use crate::{Result, config::Config, env};
use async_trait::async_trait;
use indoc::formatdoc;
use serde::Deserialize;
use std::path::Path;
use std::{fmt::Debug, sync::Arc};
use tokio::sync::OnceCell as TokioOnceCell;
const GEM_PROGRAM: &str = if cfg!(windows) { "gem.cmd" } else { "gem" };
static GEM_SOURCE: TokioOnceCell<String> = TokioOnceCell::const_new();
#[derive(Debug)]
pub struct GemBackend {
ba: Arc<BackendArg>,
}
#[async_trait]
impl Backend for GemBackend {
fn get_type(&self) -> BackendType {
BackendType::Gem
}
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn get_dependencies(&self) -> eyre::Result<Vec<&str>> {
Ok(vec!["ruby"])
}
async fn _list_remote_versions(&self, config: &Arc<Config>) -> eyre::Result<Vec<VersionInfo>> {
let source_url = self.get_gem_source(config).await;
let url = format!("{}api/v1/versions/{}.json", source_url, self.tool_name());
let response: Vec<RubyGemsVersion> = HTTP_FETCH.json(&url).await?;
let mut versions: Vec<VersionInfo> = response
.into_iter()
.map(|v| VersionInfo {
version: v.number,
created_at: v.created_at,
..Default::default()
})
.collect();
versions.reverse();
Ok(versions)
}
async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
self.warn_if_dependency_missing(
&ctx.config,
"gem",
&["ruby", "gem"],
"To use gem packages with mise, you need to install Ruby first:\n\
mise use ruby@latest",
)
.await;
CmdLineRunner::new(GEM_PROGRAM)
.arg("install")
.arg(self.tool_name())
.arg("--version")
.arg(&tv.version)
.arg("--install-dir")
.arg(tv.install_path().join("libexec"))
.with_pr(ctx.pr.as_ref())
.envs(self.dependency_env(&ctx.config).await?)
.execute()?;
env_script_all_bin_files(&tv.install_path())?;
#[cfg(unix)]
{
rewrite_gem_shebangs(&tv.install_path())?;
create_ruby_symlink(&tv.install_path())?;
}
Ok(tv)
}
}
impl GemBackend {
pub fn from_arg(ba: BackendArg) -> Self {
Self { ba: Arc::new(ba) }
}
async fn get_gem_source(&self, config: &Arc<Config>) -> &'static str {
const DEFAULT_SOURCE: &str = "https://rubygems.org/";
let env = self.dependency_env(config).await.unwrap_or_default();
match GEM_SOURCE
.get_or_try_init(|| async {
let output = crate::cmd::cmd_read_async(GEM_PROGRAM, &["sources"], &env)
.await
.map_err(|e| eyre::eyre!("failed to run `gem sources`: {e}"))?;
Ok::<_, eyre::Report>(parse_gem_source_output(&output))
})
.await
{
Ok(source) => source.as_str(),
Err(e) => {
warn!("{e}, falling back to rubygems.org");
DEFAULT_SOURCE
}
}
}
}
#[derive(Debug, Deserialize)]
struct RubyGemsVersion {
number: String,
created_at: Option<String>,
}
fn parse_gem_source_output(output: &str) -> String {
for line in output.lines() {
let line = line.trim();
if line.starts_with("http://") || line.starts_with("https://") {
return if line.ends_with('/') {
line.to_string()
} else {
format!("{}/", line)
};
}
}
"https://rubygems.org/".to_string()
}
#[cfg(unix)]
fn env_script_all_bin_files(install_path: &Path) -> eyre::Result<bool> {
let install_bin_path = install_path.join("bin");
let install_libexec_path = install_path.join("libexec");
file::create_dir_all(&install_bin_path)?;
for path in get_gem_executables(install_path)? {
let file_name = path
.file_name()
.ok_or_else(|| eyre::eyre!("invalid gem executable path: {}", path.display()))?;
let exec_path = install_bin_path.join(file_name);
let gem_exec_path = path.to_str().ok_or_else(|| {
eyre::eyre!(
"gem executable path contains invalid UTF-8: {}",
path.display()
)
})?;
let gem_home = install_libexec_path.to_str().ok_or_else(|| {
eyre::eyre!(
"libexec path contains invalid UTF-8: {}",
install_libexec_path.display()
)
})?;
file::write(
&exec_path,
formatdoc!(
r#"
#!/usr/bin/env bash
GEM_HOME="{gem_home}" exec {gem_exec_path} "$@"
"#,
gem_home = gem_home,
gem_exec_path = gem_exec_path,
),
)?;
file::make_executable(&exec_path)?;
}
Ok(true)
}
#[cfg(windows)]
fn env_script_all_bin_files(install_path: &Path) -> eyre::Result<bool> {
let install_bin_path = install_path.join("bin");
let install_libexec_path = install_path.join("libexec");
file::create_dir_all(&install_bin_path)?;
for path in get_gem_executables(install_path)? {
let file_stem = path
.file_stem()
.ok_or_else(|| eyre::eyre!("invalid gem executable path: {}", path.display()))?
.to_string_lossy();
let exec_path = install_bin_path.join(format!("{}.cmd", file_stem));
let gem_exec_path = path.to_str().ok_or_else(|| {
eyre::eyre!(
"gem executable path contains invalid UTF-8: {}",
path.display()
)
})?;
let gem_home = install_libexec_path.to_str().ok_or_else(|| {
eyre::eyre!(
"libexec path contains invalid UTF-8: {}",
install_libexec_path.display()
)
})?;
file::write(
&exec_path,
formatdoc!(
r#"@echo off
set "GEM_HOME={gem_home}"
"{gem_exec_path}" %*
"#,
gem_home = gem_home,
gem_exec_path = gem_exec_path,
),
)?;
}
Ok(true)
}
fn get_gem_executables(install_path: &Path) -> eyre::Result<Vec<std::path::PathBuf>> {
let install_libexec_bin_path = install_path.join("libexec/bin");
let files = file::ls(&install_libexec_bin_path)?
.into_iter()
.filter(|p| file::is_executable(p))
.collect();
Ok(files)
}
#[cfg(unix)]
fn create_ruby_symlink(install_path: &Path) -> eyre::Result<()> {
let libexec_bin = install_path.join("libexec/bin");
let ruby_symlink = libexec_bin.join("ruby");
if ruby_symlink.exists() || ruby_symlink.is_symlink() {
return Ok(());
}
let executables = get_gem_executables(install_path)?;
let Some(exec_path) = executables.first() else {
return Ok(());
};
let content = file::read_to_string(exec_path)?;
let lines: Vec<&str> = content.lines().collect();
let Some((_, shebang_line)) = find_ruby_shebang(&lines) else {
return Ok(());
};
let ruby_path = shebang_line
.trim_start_matches("#!")
.split_whitespace()
.next()
.unwrap_or("");
if ruby_path.is_empty() {
return Ok(());
}
if !is_mise_ruby_path(ruby_path) {
return Ok(());
}
file::make_symlink(Path::new(ruby_path), &ruby_symlink)?;
Ok(())
}
#[cfg(unix)]
fn rewrite_gem_shebangs(install_path: &Path) -> eyre::Result<()> {
let executables = get_gem_executables(install_path)?;
for exec_path in executables {
let content = file::read_to_string(&exec_path)?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
continue;
}
let (shebang_line_idx, shebang_line) = if let Some(info) = find_ruby_shebang(&lines) {
info
} else {
continue;
};
let shebang_content = shebang_line.trim_start_matches("#!");
let mut parts = shebang_content.split_whitespace();
let ruby_path = parts.next().unwrap_or("");
let shebang_args: Vec<&str> = parts.collect();
let new_shebang = if is_mise_ruby_path(ruby_path) {
match to_minor_version_shebang(ruby_path) {
Some(path) => {
if shebang_args.is_empty() {
format!("#!{path}")
} else {
format!("#!{path} {}", shebang_args.join(" "))
}
}
None => continue, }
} else {
"#!/usr/bin/env ruby".to_string()
};
let mut new_lines: Vec<&str> = lines.clone();
let new_shebang_ref: &str = &new_shebang;
new_lines[shebang_line_idx] = new_shebang_ref;
let trailing_newline = if content.ends_with('\n') { "\n" } else { "" };
let new_content = format!("{}{trailing_newline}", new_lines.join("\n"));
file::write(&exec_path, &new_content)?;
}
Ok(())
}
#[cfg(unix)]
fn find_ruby_shebang<'a>(lines: &'a [&'a str]) -> Option<(usize, &'a str)> {
let first_line = lines.first()?;
if first_line.starts_with("#!") && first_line.contains("ruby") {
return Some((0, first_line));
}
if first_line.starts_with("#!/bin/sh") {
let mut found_end = false;
for (idx, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "=end" {
found_end = true;
continue;
}
if found_end && line.starts_with("#!") && line.contains("ruby") {
return Some((idx, line));
}
}
}
None
}
#[cfg(unix)]
fn is_mise_ruby_path(ruby_path: &str) -> bool {
let ruby_installs = env::MISE_INSTALLS_DIR.join("ruby");
Path::new(ruby_path).starts_with(&ruby_installs)
}
#[cfg(unix)]
fn to_minor_version_shebang(ruby_path: &str) -> Option<String> {
let ruby_installs = env::MISE_INSTALLS_DIR.join("ruby");
let ruby_installs_str = ruby_installs.to_string_lossy();
let path = Path::new(ruby_path);
let rel_path = path.strip_prefix(&ruby_installs).ok()?;
let mut components = rel_path.components();
let version_component = components.next()?.as_os_str().to_string_lossy();
let version_str = version_component.as_ref();
let minor_version = extract_minor_version(version_str)?;
let remaining: std::path::PathBuf = components.collect();
Some(format!(
"{}/{}/{}",
ruby_installs_str,
minor_version,
remaining.display()
))
}
#[cfg(any(unix, test))]
fn extract_minor_version(version: &str) -> Option<String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 2 {
Some(format!("{}.{}", parts[0], parts[1]))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_minor_version() {
assert_eq!(extract_minor_version("3.1.0"), Some("3.1".to_string()));
assert_eq!(extract_minor_version("3.2.1"), Some("3.2".to_string()));
assert_eq!(
extract_minor_version("3.1.0-preview1"),
Some("3.1".to_string())
);
assert_eq!(extract_minor_version("2.7.8"), Some("2.7".to_string()));
assert_eq!(extract_minor_version("3"), None);
assert_eq!(extract_minor_version("latest"), None);
}
}