mise 2026.9.4

Dev tools, env vars, and tasks in one CLI
use std::{
    collections::BTreeMap,
    path::{Path, PathBuf},
    sync::Arc,
};

use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::normalize_idiomatic_contents;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::config::{Config, Settings};
use crate::env::PATH_KEY;
use crate::github::GithubRelease;
use crate::http::HTTP;
use crate::install_context::InstallContext;
use crate::toolset::{ToolVersion, Toolset};
use crate::ui::progress_report::SingleReport;
use crate::{file, github, plugins};
use async_trait::async_trait;
use eyre::{Result, bail};
use itertools::Itertools;
use versions::Versioning;
use xx::regex;

#[derive(Debug)]
pub(crate) struct RubyPlugin {
    ba: Arc<BackendArg>,
}

impl RubyPlugin {
    pub(crate) fn new() -> Self {
        Self {
            ba: plugins::core::new_backend_arg("ruby").into(),
        }
    }

    fn ruby_path(&self, tv: &ToolVersion) -> PathBuf {
        tv.install_path().join("bin").join("ruby.exe")
    }

    fn gem_path(&self, tv: &ToolVersion) -> PathBuf {
        tv.install_path().join("bin").join("gem.cmd")
    }

    async fn install_default_gems(
        &self,
        config: &Arc<Config>,
        tv: &ToolVersion,
        pr: &dyn SingleReport,
    ) -> Result<()> {
        let settings = Settings::get();
        let default_gems_file = file::replace_path(&settings.ruby.default_packages_file);
        let body = file::read_to_string(&default_gems_file).unwrap_or_default();
        let mut packages = body
            .lines()
            .filter_map(Settings::parse_default_package_line)
            .peekable();
        if packages.peek().is_some() {
            Settings::warn_default_package_file_deprecated(
                "ruby.default_packages_file",
                "ruby gem",
            );
        }
        for package in packages {
            pr.set_message(format!("install default gem: {}", package));
            let gem = self.gem_path(tv);
            let mut cmd = CmdLineRunner::new(gem)
                .with_pr(pr)
                .arg("install")
                .envs(config.env().await?)
                .env_values(tv.install_env());
            match package.split_once(' ') {
                Some((name, "--pre")) => cmd = cmd.arg(name).arg("--pre"),
                Some((name, version)) => cmd = cmd.arg(name).arg("--version").arg(version),
                None => cmd = cmd.arg(package),
            };
            cmd.env(&*PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
                .execute()?;
        }
        Ok(())
    }

    async fn test_ruby(
        &self,
        config: &Arc<Config>,
        tv: &ToolVersion,
        pr: &dyn SingleReport,
    ) -> Result<()> {
        pr.set_message("ruby -v".into());
        CmdLineRunner::new(self.ruby_path(tv))
            .with_pr(pr)
            .arg("-v")
            .envs(config.env().await?)
            .env_values(tv.install_env())
            .execute()
    }

    async fn test_gem(
        &self,
        config: &Arc<Config>,
        tv: &ToolVersion,
        pr: &dyn SingleReport,
    ) -> Result<()> {
        pr.set_message("gem -v".into());
        CmdLineRunner::new(self.gem_path(tv))
            .with_pr(pr)
            .arg("-v")
            .envs(config.env().await?)
            .env_values(tv.install_env())
            .env(&*PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
            .execute()
    }

    fn install_rubygems_hook(&self, tv: &ToolVersion) -> Result<()> {
        let site_ruby_path = tv.install_path().join("lib/ruby/site_ruby");
        let f = site_ruby_path.join("rubygems_plugin.rb");
        file::create_dir_all(site_ruby_path)?;
        file::write(f, include_str!("assets/rubygems_plugin.rb"))?;
        Ok(())
    }

    async fn download(&self, tv: &ToolVersion, pr: &dyn SingleReport) -> Result<PathBuf> {
        // A lockfile pins the exact archive, and `verify_checksum` checks the
        // download against the checksum recorded beside it. Resolving afresh
        // here could pick a newer build revision than the lock describes and
        // fail that check; the pinned URL has to win.
        let locked = locked_archive(
            tv.lock_platforms
                .get(&self.get_platform_key())
                .and_then(|pi| pi.url.as_deref()),
        );
        let (url, filename) = match locked {
            Some(locked) => locked,
            None => {
                let artifact =
                    super::ruby_common::resolve_rubyinstaller_artifact(&tv.version).await;
                (artifact.url, artifact.filename)
            }
        };
        let tarball_path = tv.download_path().join(&filename);

        pr.set_message(format!("downloading {filename}"));
        HTTP.download_file(&url, &tarball_path, Some(pr)).await?;

        Ok(tarball_path)
    }

    async fn install(
        &self,
        ctx: &InstallContext,
        tv: &ToolVersion,
        tarball_path: &Path,
    ) -> Result<()> {
        let filename = tarball_path.file_name().unwrap().to_string_lossy();
        ctx.pr.set_message(format!("extract {filename}"));
        file::remove_all(tv.install_path())?;
        file::un7z(tarball_path, &tv.download_path(), &Default::default())?;
        // The archive holds a single top-level directory named after the archive
        // itself, so derive it instead of rebuilding the version/revision/arch
        // triple -- the build revision is no longer fixed at 1 (discussion #5227).
        let dir_name = filename.strip_suffix(".7z").unwrap_or(&filename);
        file::move_file(tv.download_path().join(dir_name), tv.install_path())?;
        Ok(())
    }

    async fn verify(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> {
        self.test_ruby(&ctx.config, tv, ctx.pr.as_ref()).await
    }
}

#[async_trait]
impl Backend for RubyPlugin {
    fn ba(&self) -> &Arc<BackendArg> {
        &self.ba
    }
    async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
        // TODO: use windows set of versions
        //  match self.core.fetch_remote_versions_from_mise() {
        //      Ok(Some(versions)) => return Ok(versions),
        //      Ok(None) => {}
        //      Err(e) => warn!("failed to fetch remote versions: {}", e),
        //  }
        let releases: Vec<GithubRelease> = github::list_releases("oneclick/rubyinstaller2").await?;
        let versions = releases
            .into_iter()
            .filter_map(|r| {
                let created_at = Some(r.released_at().to_string());
                regex!(r"RubyInstaller-([0-9.]+)-.*")
                    .replace(&r.tag_name, "$1")
                    .parse::<String>()
                    .ok()
                    .map(|version| VersionInfo {
                        version,
                        created_at,
                        ..Default::default()
                    })
            })
            .unique_by(|v| v.version.clone())
            .sorted_by_cached_key(|v| (Versioning::new(&v.version), v.version.clone()))
            .collect();
        Ok(versions)
    }

    async fn _parse_idiomatic_file(&self, path: &Path) -> Result<Vec<String>> {
        let v = match path.file_name() {
            Some(name) if name == "Gemfile" => super::ruby_common::parse_gemfile(path)?,
            _ => {
                // .ruby-version
                let body = normalize_idiomatic_contents(&file::read_to_string(path)?);
                body.trim()
                    .trim_start_matches("ruby-")
                    .trim_start_matches('v')
                    .to_string()
            }
        };
        if v.is_empty() {
            return Ok(vec![]);
        }
        Ok(vec![v])
    }

    async fn install_version_(
        &self,
        ctx: &InstallContext,
        mut tv: ToolVersion,
    ) -> eyre::Result<ToolVersion> {
        if !super::ruby_common::is_mri_version(&tv.version) {
            bail!(
                "Ruby engine '{}' is not supported on Windows.\n\
                 Only standard MRI Ruby versions can be installed via RubyInstaller2.",
                tv.version
            );
        }
        let tarball = self.download(&tv, ctx.pr.as_ref()).await?;
        self.verify_checksum(ctx, &mut tv, &tarball)?;
        self.install(ctx, &tv, &tarball).await?;
        self.verify(ctx, &tv).await?;
        self.install_rubygems_hook(&tv)?;
        self.test_gem(&ctx.config, &tv, ctx.pr.as_ref()).await?;
        if let Err(err) = self
            .install_default_gems(&ctx.config, &tv, ctx.pr.as_ref())
            .await
        {
            warn!("failed to install default ruby gems {err:#}");
        }
        Ok(tv)
    }

    async fn exec_env(
        &self,
        _config: &Arc<Config>,
        _ts: &Toolset,
        _tv: &ToolVersion,
    ) -> eyre::Result<BTreeMap<String, String>> {
        let map = BTreeMap::new();
        // No modification to RUBYLIB
        Ok(map)
    }
}

/// The archive a lockfile already pins for this platform, as `(url, filename)`.
/// `None` when nothing is locked or the URL yields no filename, in which case
/// the caller resolves the archive itself.
fn locked_archive(locked_url: Option<&str>) -> Option<(String, String)> {
    let url = locked_url?;
    let filename = crate::backend::static_helpers::get_filename_from_url(url);
    (!filename.is_empty()).then(|| (url.to_string(), filename))
}

#[cfg(test)]
mod tests {
    use crate::config::Config;
    use pretty_assertions::assert_eq;

    use super::*;

    /// A lockfile pins the archive whose checksum `verify_checksum` will apply,
    /// so a locked URL has to be used verbatim rather than re-resolved to a
    /// newer build revision.
    #[test]
    fn locked_archive_is_used_verbatim() {
        let url = "https://github.com/oneclick/rubyinstaller2/releases/download/RubyInstaller-3.4.4-1/rubyinstaller-3.4.4-1-x64.7z";
        assert_eq!(
            locked_archive(Some(url)),
            Some((url.to_string(), "rubyinstaller-3.4.4-1-x64.7z".to_string()))
        );
    }

    #[test]
    fn locked_archive_is_none_without_a_lock() {
        assert_eq!(locked_archive(None), None);
    }

    #[tokio::test]
    async fn test_list_versions_matching() {
        let config = Config::get().await.unwrap();
        let plugin = RubyPlugin::new();
        assert!(
            !plugin
                .list_versions_matching(&config, "3")
                .await
                .unwrap()
                .is_empty(),
            "versions for 3 should not be empty"
        );
        assert!(
            !plugin
                .list_versions_matching(&config, "truffleruby-24")
                .await
                .unwrap()
                .is_empty(),
            "versions for truffleruby-24 should not be empty"
        );
        assert!(
            !plugin
                .list_versions_matching(&config, "truffleruby+graalvm-24")
                .await
                .unwrap()
                .is_empty(),
            "versions for truffleruby+graalvm-24 should not be empty"
        );
    }
}