From 986f9ae37f06392fd7f1e8f1cbd12a41cc6c93c0 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Sun, 9 Nov 2025 17:37:02 +0800 Subject: [PATCH 1/4] Refactor Rust toolchain management - Reuse system installed rustup - Reuse installed rustup across hooks - Reuse installed Rust toolchains across hooks --- crates/prek-consts/src/env_vars.rs | 2 +- docs/todo.md | 2 +- src/languages/golang/golang.rs | 2 +- src/languages/rust/installer.rs | 332 +++++++---------------------- src/languages/rust/mod.rs | 1 + src/languages/rust/rust.rs | 59 ++--- src/languages/rust/rustup.rs | 285 +++++++++++++++++++++++++ src/languages/rust/version.rs | 219 ++++++++++++------- src/store.rs | 8 +- tests/languages/rust.rs | 33 +++ tests/run.rs | 30 +-- 11 files changed, 573 insertions(+), 400 deletions(-) create mode 100644 src/languages/rust/rustup.rs diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index 282cc6af2..b23a6e8b7 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -34,7 +34,7 @@ impl EnvVars { "PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT"; pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = "PREK_INTERNAL__GO_BINARY_NAME"; pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = "PREK_INTERNAL__NODE_BINARY_NAME"; - pub const PREK_INTERNAL__RUST_BINARY_NAME: &'static str = "PREK_INTERNAL__RUST_BINARY_NAME"; + pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = "PREK_INTERNAL__RUST_BINARY_NAME"; pub const PREK_GENERATE: &'static str = "PREK_GENERATE"; // Python & uv related diff --git a/docs/todo.md b/docs/todo.md index b9b71dcc5..95324ef42 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,6 +15,7 @@ The original pre-commit supports hooks written in 10+ languages. The table below | python ⭐ | ✅ Supported | — | `prek` supports automatic version management of Python toolchains. | | node | ✅ Supported | — | | | golang | ✅ Supported | — | | +| rust | ✅ Supported | — | | | lua | ✅ Supported | — | | | system | ✅ Supported | — | | | script | ✅ Supported | — | | @@ -24,7 +25,6 @@ The original pre-commit supports hooks written in 10+ languages. The table below | fail | ✅ Supported | — | | | deno ⭐ | 🚧 WIP | — | Experimental support in `prek`; upstream `pre-commit` lacks a native `deno` language. | | ruby | 🚧 WIP | [#43](https://github.com/j178/prek/issues/43) | `prek` does not currently support downloading new Ruby versions, but can use multiple simultaneously installed interpreters | -| rust | 🚧 Planned | [#44](https://github.com/j178/prek/issues/44) | | | conda | 🚧 Planned | [#52](https://github.com/j178/prek/issues/52) | | | coursier | 🚧 Planned | [#53](https://github.com/j178/prek/issues/53) | | | dart | 🚧 Planned | [#51](https://github.com/j178/prek/issues/51) | | diff --git a/src/languages/golang/golang.rs b/src/languages/golang/golang.rs index 85980a06a..b6d10bd3c 100644 --- a/src/languages/golang/golang.rs +++ b/src/languages/golang/golang.rs @@ -30,7 +30,7 @@ impl LanguageImpl for Golang { let progress = reporter.on_install_start(&hook); // 1. Install Go - let go_dir = store.tools_path(crate::store::ToolBucket::Go); + let go_dir = store.tools_path(ToolBucket::Go); let installer = GoInstaller::new(go_dir); let (version, allows_download) = match &hook.language_request { diff --git a/src/languages/rust/installer.rs b/src/languages/rust/installer.rs index ad8b19d20..868de3c93 100644 --- a/src/languages/rust/installer.rs +++ b/src/languages/rust/installer.rs @@ -1,58 +1,42 @@ -use std::env::consts::EXE_EXTENSION; use std::fmt::Display; use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::sync::LazyLock; use anyhow::{Context, Result}; use itertools::Itertools; use prek_consts::env_vars::EnvVars; -use tracing::{debug, trace, warn}; +use semver::Version; +use tracing::{debug, trace}; use crate::fs::LockedFile; use crate::languages::rust::RustRequest; -use crate::languages::rust::version::RustVersion; +use crate::languages::rust::rustup::{Rustup, ToolchainInfo}; +use crate::languages::rust::version::{Channel, RustVersion}; use crate::process::Cmd; -use crate::store::Store; pub(crate) struct RustResult { - path: PathBuf, + toolchain: PathBuf, version: RustVersion, from_system: bool, } impl Display for RustResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}@{}", self.path.display(), self.version)?; + write!(f, "{}@{}", self.toolchain.display(), *self.version)?; Ok(()) } } -/// Override the Rust binary name for testing. -static RUST_BINARY_NAME: LazyLock = LazyLock::new(|| { - if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__RUST_BINARY_NAME) { - name - } else { - "rustc".to_string() - } -}); - impl RustResult { - fn from_executable(path: PathBuf, from_system: bool) -> Self { + pub(crate) fn from_dir(dir: &Path, from_system: bool) -> Self { Self { - path, - from_system, + toolchain: dir.to_path_buf(), version: RustVersion::default(), + from_system, } } - pub(crate) fn from_dir(dir: &Path, from_system: bool) -> Self { - let rustc = bin_dir(dir).join("rustc").with_extension(EXE_EXTENSION); - Self::from_executable(rustc, from_system) - } - - pub(crate) fn bin(&self) -> &Path { - &self.path + pub(crate) fn toolchain(&self) -> &Path { + &self.toolchain } pub(crate) fn version(&self) -> &RustVersion { @@ -63,48 +47,21 @@ impl RustResult { self.from_system } - pub(crate) fn cmd(&self, summary: &str) -> Cmd { - let mut cmd = Cmd::new(&self.path, summary); - cmd.env(EnvVars::RUSTUP_AUTO_INSTALL, "0"); - cmd - } - pub(crate) fn with_version(mut self, version: RustVersion) -> Self { self.version = version; self } pub(crate) async fn fill_version(mut self) -> Result { - let output = self - .cmd("rustc version") - .arg("--version") - .check(true) - .output() - .await?; - - // e.g. "rustc 1.70.0 (90c541806 2023-05-31)" - let version_str = str::from_utf8(&output.stdout)?; - let version_str = version_str.split_ascii_whitespace().nth(1).ok_or_else(|| { - anyhow::anyhow!("Failed to parse Rust version from output: {version_str}") - })?; - - let version = RustVersion::from_str(version_str)?; - - self.version = version; - - Ok(self) - } + let rustc = self + .toolchain + .join("bin") + .join("rustc") + .with_extension(std::env::consts::EXE_EXTENSION); - pub(crate) async fn fill_version_with_toolchain( - mut self, - toolchain: &str, - rustup_home: &Path, - ) -> Result { - let output = self - .cmd("rustc version") + let output = Cmd::new(rustc, "rustc --version") .arg("--version") - .env(EnvVars::RUSTUP_TOOLCHAIN, toolchain) - .env(EnvVars::RUSTUP_HOME, rustup_home) + .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .check(true) .output() .await?; @@ -115,7 +72,9 @@ impl RustResult { anyhow::anyhow!("Failed to parse Rust version from output: {version_str}") })?; - let version = RustVersion::from_str(version_str)?; + let version = Version::parse(version_str)?; + let version = RustVersion::from_path(&version, &self.toolchain); + self.version = version; Ok(self) @@ -123,46 +82,30 @@ impl RustResult { } pub(crate) struct RustInstaller { - root: PathBuf, + rustup: Rustup, } impl RustInstaller { - pub(crate) fn new(root: PathBuf) -> Self { - Self { root } + pub(crate) fn new(rustup: Rustup) -> Self { + Self { rustup } } pub(crate) async fn install( &self, - _store: &Store, request: &RustRequest, allows_download: bool, ) -> Result { - fs_err::tokio::create_dir_all(&self.root).await?; - let _lock = LockedFile::acquire(self.root.join(".lock"), "rust").await?; + let rustup_home = self.rustup.rustup_home(); + fs_err::tokio::create_dir_all(rustup_home).await?; + let _lock = LockedFile::acquire(rustup_home.join(".lock"), "rustup").await?; - // Check cache first - if let Ok(rust) = self.find_installed(request) { + // Check installed + if let Ok(rust) = self.find_installed(request).await { trace!(%rust, "Found installed rust"); - let toolchain = rust - .bin() - .parent() - .and_then(|p| p.parent()) - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .map(ToString::to_string) - .context("Failed to extract toolchain name")?; - let rustup_home = rustup_home_dir( - rust.bin() - .parent() - .and_then(|p| p.parent()) - .context("Failed to get rust dir")?, - ); - return rust - .fill_version_with_toolchain(&toolchain, &rustup_home) - .await; + return Ok(rust); } - // Check system second + // Check system rust if let Some(rust) = self.find_system_rust(request).await? { trace!(%rust, "Using system rust"); return Ok(rust); @@ -172,58 +115,28 @@ impl RustInstaller { anyhow::bail!("No suitable system Rust version found and downloads are disabled"); } + // Install new toolchain let toolchain = self.resolve_version(request).await?; - let target_dir = self.root.join(&toolchain); - install_rust_with_toolchain(&toolchain, &target_dir).await?; - - let rustup_home = rustup_home_dir(&target_dir); - RustResult::from_dir(&target_dir, false) - .fill_version_with_toolchain(&toolchain, &rustup_home) - .await + self.download(&toolchain).await } - fn find_installed(&self, request: &RustRequest) -> Result { - let mut installed = fs_err::read_dir(&self.root) - .ok() + async fn find_installed(&self, request: &RustRequest) -> Result { + let toolchains: Vec = self.rustup.list_installed_toolchains().await?; + + let installed = toolchains .into_iter() - .flatten() - .filter_map(|entry| match entry { - Ok(entry) => Some(entry), - Err(e) => { - warn!(?e, "Failed to read entry"); - None - } - }) - .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir())) - .map(|entry| { - let dir_name = entry.file_name(); - let dir_str = dir_name.to_string_lossy(); - - // Try to parse as version, or use as channel name - let version = RustVersion::from_str(&dir_str).ok(); - (dir_str.to_string(), version, entry.path()) - }) - .sorted_unstable_by(|(_, a, _), (_, b, _)| { - // Sort by version if available, otherwise by name - match (a, b) { - (Some(a), Some(b)) => b.cmp(a), // reverse for newest first - _ => std::cmp::Ordering::Equal, - } - }); + .sorted_unstable_by(|a, b| b.version.cmp(&a.version)); installed - .find_map(|(name, version, path)| { - let matches = match (request, version) { - (RustRequest::Channel(ch), _) => &name == ch, - (_, Some(v)) => request.matches(&v, Some(&path)), - _ => false, - }; + .into_iter() + .find_map(|info| { + let matches = request.matches(&info.version, Some(&info.path)); if matches { - trace!(name = %name, "Found matching installed rust"); - Some(RustResult::from_dir(&path, false)) + trace!(name = %info.name, "Found matching installed rust"); + Some(RustResult::from_dir(&info.path, false).with_version(info.version)) } else { - trace!(name = %name, "Installed rust does not match request"); + trace!(name = %info.name, "Installed rust does not match request"); None } }) @@ -231,37 +144,21 @@ impl RustInstaller { } async fn find_system_rust(&self, rust_request: &RustRequest) -> Result> { - let rust_paths = match which::which_all(&*RUST_BINARY_NAME) { - Ok(paths) => paths, - Err(e) => { - debug!("No rustc executables found in PATH: {}", e); - return Ok(None); - } - }; - - for rust_path in rust_paths { - match RustResult::from_executable(rust_path, true) - .fill_version() - .await - { - Ok(rust) => { - // Check if this version matches the request - if rust_request.matches(&rust.version, Some(&rust.path)) { - trace!( - %rust, - "Found matching system rust" - ); - return Ok(Some(rust)); - } - trace!( - %rust, - "System rust does not match requested version" - ); - } - Err(e) => { - warn!(?e, "Failed to get version for system rust"); - } + let toolchains: Vec = self.rustup.list_system_toolchains().await?; + + let installed = toolchains + .into_iter() + .sorted_unstable_by(|a, b| b.version.cmp(&a.version)); + + for info in installed { + let matches = rust_request.matches(&info.version, Some(&info.path)); + + if matches { + trace!(name = %info.name, "Found matching system rust"); + let rust = RustResult::from_dir(&info.path, true).with_version(info.version); + return Ok(Some(rust)); } + trace!(name = %info.name, "System rust does not match request"); } debug!( @@ -271,11 +168,10 @@ impl RustInstaller { Ok(None) } - async fn resolve_version(&self, req: &RustRequest) -> Result { + async fn resolve_version(&self, req: &RustRequest) -> Result { match req { - RustRequest::Any => Ok("stable".to_string()), - RustRequest::Channel(ch) => Ok(ch.clone()), - RustRequest::Path(p) => Ok(p.to_string_lossy().to_string()), + RustRequest::Any => Ok(RustVersion::from_channel(Channel::Stable)), + RustRequest::Channel(ch) => Ok(RustVersion::from_channel(*ch)), RustRequest::Major(_) | RustRequest::MajorMinor(_, _) @@ -293,7 +189,9 @@ impl RustInstaller { .filter_map(|line| { let tag = line.split('\t').nth(1)?; let tag = tag.strip_prefix("refs/tags/")?; - RustVersion::from_str(tag).ok() + Version::parse(tag) + .ok() + .map(|v| RustVersion::from_version(&v)) }) .sorted_unstable_by(|a, b| b.cmp(a)) .collect(); @@ -301,101 +199,29 @@ impl RustInstaller { let version = versions .into_iter() .find(|version| req.matches(version, None)) - .context("Version not found on remote")?; - Ok(version.to_string()) + .with_context(|| format!("Version `{req}` not found on remote"))?; + Ok(version) } } } -} - -pub(crate) fn bin_dir(env_path: &Path) -> PathBuf { - env_path.join("bin") -} - -/// Returns the path to the `RUSTUP_HOME` directory within a managed rust installation. -pub(crate) fn rustup_home_dir(env_path: &Path) -> PathBuf { - env_path.join("rustup") -} - -#[cfg(unix)] -fn make_executable(filename: impl AsRef) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; - let metadata = std::fs::metadata(filename.as_ref())?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o111); - std::fs::set_permissions(filename.as_ref(), permissions)?; - - Ok(()) -} - -#[cfg(not(unix))] -#[allow(clippy::unnecessary_wraps)] -fn make_executable(_filename: impl AsRef) -> std::io::Result<()> { - Ok(()) -} + async fn download(&self, toolchain: &RustVersion) -> Result { + let toolchain = toolchain.to_toolchain_name(); + debug!(%toolchain, "Installing Rust toolchain"); -async fn install_rust_with_toolchain(toolchain: &str, target_dir: &Path) -> Result<()> { - // Use a persistent RUSTUP_HOME within the target directory so toolchains are preserved - let rustup_home = rustup_home_dir(target_dir); - fs_err::tokio::create_dir_all(&rustup_home).await?; - - let rustup_bin = bin_dir(target_dir) - .join("rustup") - .with_extension(EXE_EXTENSION); - - // Check if rustup already exists at the expected location - if !rustup_bin.exists() { - // Download rustup-init to a temporary location - let rustup_init_dir = tempfile::tempdir()?; - - let url = if cfg!(windows) { - "https://win.rustup.rs/x86_64" - } else { - "https://sh.rustup.rs" - }; - - let resp = crate::languages::REQWEST_CLIENT - .get(url) - .send() + let toolchain_dir = self + .rustup + .install_toolchain(&toolchain) .await - .context("Failed to download rustup-init")?; + .context("Failed to install Rust toolchain")?; - if !resp.status().is_success() { - anyhow::bail!("Failed to download rustup-init: {}", resp.status()); - } - - let rustup_init = rustup_init_dir - .path() - .join("rustup-init") - .with_extension(EXE_EXTENSION); - fs_err::tokio::write(&rustup_init, resp.bytes().await?).await?; - make_executable(&rustup_init)?; - - // Install rustup into CARGO_HOME/bin, with RUSTUP_HOME in the persistent location - Cmd::new(&rustup_init, "install rustup") - .args([ - "-y", - "--quiet", - "--no-modify-path", - "--default-toolchain", - "none", - ]) - .env(EnvVars::CARGO_HOME, target_dir) - .env(EnvVars::RUSTUP_HOME, &rustup_home) - .check(true) - .output() + let rust = RustResult::from_dir(&toolchain_dir, false) + .fill_version() .await?; + Ok(rust) } +} - // Install the requested toolchain into our persistent RUSTUP_HOME - Cmd::new(&rustup_bin, "install toolchain") - .args(["toolchain", "install", "--no-self-update", toolchain]) - .env(EnvVars::CARGO_HOME, target_dir) - .env(EnvVars::RUSTUP_HOME, &rustup_home) - .check(true) - .output() - .await?; - - Ok(()) +pub(crate) fn bin_dir(env_path: &Path) -> PathBuf { + env_path.join("bin") } diff --git a/src/languages/rust/mod.rs b/src/languages/rust/mod.rs index ebf33a989..37c83b22e 100644 --- a/src/languages/rust/mod.rs +++ b/src/languages/rust/mod.rs @@ -1,6 +1,7 @@ mod installer; #[allow(clippy::module_inception)] mod rust; +mod rustup; mod version; pub(crate) use rust::Rust; diff --git a/src/languages/rust/rust.rs b/src/languages/rust/rust.rs index b9e965819..004506470 100644 --- a/src/languages/rust/rust.rs +++ b/src/languages/rust/rust.rs @@ -12,11 +12,13 @@ use crate::cli::reporter::HookInstallReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageImpl; use crate::languages::rust::RustRequest; -use crate::languages::rust::installer::{RustInstaller, rustup_home_dir}; +use crate::languages::rust::installer::RustInstaller; +use crate::languages::rust::rustup::Rustup; +use crate::languages::rust::version::EXTRA_KEY_CHANNEL; use crate::languages::version::LanguageRequest; use crate::process::Cmd; use crate::run::{prepend_paths, run_by_batch}; -use crate::store::{Store, ToolBucket}; +use crate::store::{CacheBucket, Store, ToolBucket}; fn format_cargo_dependency(dep: &str) -> String { let (name, version) = dep.split_once(':').unwrap_or((dep, "")); @@ -141,8 +143,10 @@ impl LanguageImpl for Rust { let progress = reporter.on_install_start(&hook); // 1. Install Rust - let rust_dir = store.tools_path(ToolBucket::Rust); - let installer = RustInstaller::new(rust_dir); + let cargo_home = store.cache_path(CacheBucket::Cargo); + let rustup_dir = store.tools_path(ToolBucket::Rustup); + let rustup = Rustup::install(store, &rustup_dir).await?; + let installer = RustInstaller::new(rustup); let (version, allows_download) = match &hook.language_request { LanguageRequest::Any { system_only } => (&RustRequest::Any, !system_only), @@ -151,7 +155,7 @@ impl LanguageImpl for Rust { }; let rust = installer - .install(store, version, allows_download) + .install(version, allows_download) .await .context("Failed to install rust")?; @@ -160,17 +164,17 @@ impl LanguageImpl for Rust { hook.dependencies().clone(), &store.hooks_dir(), )?; - info.with_toolchain(rust.bin().to_path_buf()) + info.with_toolchain(rust.toolchain().to_path_buf()) .with_language_version(rust.version().deref().clone()); // Store the channel name for cache matching match version { RustRequest::Channel(channel) => { - info.with_extra("rust_channel", channel); + info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); } RustRequest::Any => { // Any resolves to "stable" in resolve_version - info.with_extra("rust_channel", "stable"); + info.with_extra(EXTRA_KEY_CHANNEL, "stable"); } _ => {} } @@ -179,8 +183,6 @@ impl LanguageImpl for Rust { fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?; // 3. Install dependencies - let cargo_home = &info.env_path; - // Split dependencies by cli: prefix let (cli_deps, lib_deps): (Vec<_>, Vec<_>) = hook.additional_dependencies.iter().partition_map(|dep| { @@ -205,10 +207,10 @@ impl LanguageImpl for Rust { // For single packages without lib deps, use cargo install directly Cmd::new("cargo", "install local") .args(["install", "--bins", "--root"]) - .arg(cargo_home) + .arg(&info.env_path) .args(["--path", "."]) .current_dir(&package_dir) - .env(EnvVars::CARGO_HOME, cargo_home) + .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .remove_git_env() .check(true) @@ -225,7 +227,7 @@ impl LanguageImpl for Rust { .arg("--target-dir") .arg(&target_dir) .current_dir(repo) - .env(EnvVars::CARGO_HOME, cargo_home) + .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .remove_git_env() .check(true) @@ -278,7 +280,7 @@ impl LanguageImpl for Rust { cmd.arg(format_cargo_dependency(dep.as_str())); } cmd.current_dir(&manifest_dir) - .env(EnvVars::CARGO_HOME, cargo_home) + .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .remove_git_env() .check(true) @@ -301,7 +303,7 @@ impl LanguageImpl for Rust { } cmd.current_dir(&package_dir) - .env(EnvVars::CARGO_HOME, cargo_home) + .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .remove_git_env() .check(true) @@ -318,12 +320,12 @@ impl LanguageImpl for Rust { let (package, version) = cli_dep.split_once(':').unwrap_or((cli_dep, "")); let mut cmd = Cmd::new("cargo", "install cli dep"); cmd.args(["install", "--bins", "--root"]) - .arg(cargo_home) + .arg(&info.env_path) .arg(package); if !version.is_empty() { cmd.args(["--version", version]); } - cmd.env(EnvVars::CARGO_HOME, cargo_home) + cmd.env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") .remove_git_env() .check(true) @@ -355,24 +357,10 @@ impl LanguageImpl for Rust { let info = hook.install_info().expect("Rust hook must be installed"); let rust_bin = bin_dir(env_dir); - let rust_tools = store.tools_path(ToolBucket::Rust); - let rustc_bin = info.toolchain.parent().expect("Rust bin should exist"); - - // Determine if this is a managed (non-system) Rust installation - let rust_envs = if rustc_bin.starts_with(&rust_tools) { - let toolchain = info.language_version.to_string(); - // Get the toolchain directory (parent of bin/) - let toolchain_dir = rustc_bin.parent().expect("Toolchain dir should exist"); - let rustup_home = rustup_home_dir(toolchain_dir); - vec![ - (EnvVars::RUSTUP_TOOLCHAIN, PathBuf::from(toolchain)), - (EnvVars::RUSTUP_HOME, rustup_home), - ] - } else { - vec![] - }; + let cargo_home = store.cache_path(CacheBucket::Cargo); + let rustc_bin = bin_dir(&info.toolchain); - let new_path = prepend_paths(&[&rust_bin, rustc_bin]).context("Failed to join PATH")?; + let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?; let entry = hook.entry.resolve(Some(&new_path))?; let run = async |batch: &[&Path]| { @@ -380,9 +368,8 @@ impl LanguageImpl for Rust { .current_dir(hook.work_dir()) .args(&entry[1..]) .env(EnvVars::PATH, &new_path) - .env(EnvVars::CARGO_HOME, env_dir) + .env(EnvVars::CARGO_HOME, &cargo_home) .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") - .envs(rust_envs.iter().cloned()) .args(&hook.args) .args(batch) .check(false) diff --git a/src/languages/rust/rustup.rs b/src/languages/rust/rustup.rs new file mode 100644 index 000000000..6381f8bb4 --- /dev/null +++ b/src/languages/rust/rustup.rs @@ -0,0 +1,285 @@ +use std::env::consts::EXE_EXTENSION; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use crate::fs::LockedFile; +use crate::languages::REQWEST_CLIENT; +use crate::languages::rust::version::RustVersion; +use crate::process::Cmd; +use crate::store::Store; + +use anyhow::{Context, Result}; +use futures::{StreamExt, TryStreamExt, stream}; +use prek_consts::env_vars::EnvVars; +use semver::Version; +use target_lexicon::HOST; +use tracing::{debug, trace}; + +#[derive(Clone)] +pub(crate) struct Rustup { + bin: PathBuf, + rustup_home: PathBuf, +} + +pub(crate) struct ToolchainInfo { + pub(crate) name: String, + pub(crate) path: PathBuf, + pub(crate) version: RustVersion, +} + +static RUSTUP_BINARY_NAME: LazyLock = LazyLock::new(|| { + EnvVars::var(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME) + .unwrap_or_else(|_| "rustup".to_string()) +}); + +impl Rustup { + pub(crate) fn rustup_home(&self) -> &Path { + &self.rustup_home + } + + /// Install rustup if not already installed. + pub(crate) async fn install(store: &Store, rustup_home: &Path) -> Result { + // 1) Check system installed `rustup` + if let Ok(rustup_path) = which::which(&*RUSTUP_BINARY_NAME) { + trace!("Using system installed rustup at {}", rustup_path.display()); + return Ok(Self { + bin: rustup_path, + rustup_home: rustup_home.to_path_buf(), + }); + } + + // 2) Check if already installed in store + let rustup_path = rustup_home.join("rustup").with_extension(EXE_EXTENSION); + + if rustup_path.is_file() { + trace!("Using managed rustup at {}", rustup_path.display()); + return Ok(Self { + bin: rustup_path, + rustup_home: rustup_home.to_path_buf(), + }); + } + + // 3) Install rustup + fs_err::tokio::create_dir_all(&rustup_home).await?; + let _lock = LockedFile::acquire(rustup_home.join(".lock"), "rustup").await?; + + if rustup_path.is_file() { + trace!("Using managed rustup at {}", rustup_path.display()); + return Ok(Self { + bin: rustup_path, + rustup_home: rustup_home.to_path_buf(), + }); + } + + Self::download(store, rustup_home) + .await + .context("Failed to install rustup") + } + + async fn download(store: &Store, rustup_home: &Path) -> Result { + let triple = HOST.to_string(); + let filename = if cfg!(windows) { + "rustup-init.exe" + } else { + "rustup-init" + }; + let url = format!("https://static.rust-lang.org/rustup/dist/{triple}/{filename}"); + // Save "rustup-init" as "rustup", this is what "rustup-init" does when setting up. + let target = rustup_home.join("rustup").with_extension(EXE_EXTENSION); + + let temp_dir = tempfile::tempdir_in(store.scratch_path())?; + debug!(url = %url, temp_dir = ?temp_dir.path(), "Downloading"); + + let tmp_target = temp_dir.path().join(filename); + let response = REQWEST_CLIENT + .get(&url) + .send() + .await + .with_context(|| format!("Failed to download file from {url}"))?; + if !response.status().is_success() { + anyhow::bail!( + "Failed to download file from {}: {}", + url, + response.status() + ); + } + + let bytes = response.bytes().await?; + fs_err::tokio::write(&tmp_target, bytes).await?; + + make_executable(&tmp_target)?; + + // Move to final location + if target.exists() { + debug!(path = %target.display(), "Removing existing rustup"); + fs_err::tokio::remove_file(&target).await?; + } + debug!(path = %target.display(), "Installing rustup"); + fs_err::tokio::rename(&tmp_target, &target).await?; + + Ok(Self { + bin: target, + rustup_home: rustup_home.to_path_buf(), + }) + } + + pub(crate) async fn install_toolchain(&self, toolchain: &str) -> Result { + let output = Cmd::new(&self.bin, "rustup toolchain install") + .env(EnvVars::RUSTUP_HOME, &self.rustup_home) + .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") + .arg("toolchain") + .arg("install") + .arg("--no-self-update") + .arg("--profile") + .arg("minimal") + .arg(toolchain) + .check(true) + .output() + .await + .with_context(|| format!("Failed to install rust toolchain {toolchain}"))?; + + // Parse installed toolchain name from output + let stdout = String::from_utf8_lossy(&output.stdout); + let installed_name = stdout + .lines() + .find_map(|line| { + let line = line.trim(); + let (name, _) = line.split_once(" installed")?; + let name = name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + }) + .with_context(|| { + format!( + "Unable to detect installed toolchain name from rustup output for `{toolchain}`" + ) + })?; + + Ok(self.rustup_home.join("toolchains").join(installed_name)) + } + + /// List installed toolchains managed by prek. + pub(crate) async fn list_installed_toolchains(&self) -> Result> { + let output = Cmd::new(&self.bin, "rustup list toolchains") + .arg("toolchain") + .arg("list") + .arg("-v") + .env(EnvVars::RUSTUP_HOME, &self.rustup_home) + .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") + .check(true) + .output() + .await + .context("Failed to list installed toolchains")?; + + let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)? + .lines() + .filter_map(parse_toolchain_line) + .collect(); + + let infos: Vec = stream::iter(entries) + .map(async move |(name, path)| toolchain_info(name, path).await) + .buffer_unordered(8) + .try_collect() + .await?; + + Ok(infos) + } + + /// List system-installed Rust toolchains. + pub(crate) async fn list_system_toolchains(&self) -> Result> { + let output = Cmd::new(&self.bin, "rustup toolchain list") + .arg("toolchain") + .arg("list") + .arg("-v") + .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") + .check(true) + .output() + .await + .context("Failed to list system toolchains")?; + + let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)? + .lines() + .filter_map(parse_toolchain_line) + .collect(); + + let infos: Vec = stream::iter(entries) + .map(async move |(name, path)| toolchain_info(name, path).await) + .buffer_unordered(8) + .try_collect() + .await?; + + Ok(infos) + } +} + +fn parse_toolchain_line(line: &str) -> Option<(String, PathBuf)> { + // Typical formats: + // "stable-aarch64-apple-darwin (default) /Users/me/.rustup/toolchains/stable-aarch64-apple-darwin" + // "nightly-x86_64-unknown-linux-gnu /home/me/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu" + let parts: Vec<_> = line.split_whitespace().collect(); + let name = (*parts.first()?).to_string(); + let path = parts.last()?; + let path = PathBuf::from(path); + if path.exists() { + Some((name, path)) + } else { + None + } +} + +async fn toolchain_info(name: String, toolchain_dir: PathBuf) -> Result { + let rustc = toolchain_dir + .join("bin") + .join("rustc") + .with_extension(EXE_EXTENSION); + + let output = Cmd::new(&rustc, "rustc version") + .arg("--version") + .check(true) + .output() + .await + .with_context(|| format!("Failed to read version from {}", rustc.display()))?; + + let version_str = str::from_utf8(&output.stdout)? + .split_whitespace() + .nth(1) + .context("Failed to parse rustc --version output")?; + let version = Version::parse(version_str)?; + let version = RustVersion::from_path(&version, &toolchain_dir); + + Ok(ToolchainInfo { + name, + path: toolchain_dir, + version, + }) +} + +fn make_executable(path: &Path) -> std::io::Result<()> { + #[allow(clippy::unnecessary_wraps)] + #[cfg(windows)] + fn inner(_: &Path) -> std::io::Result<()> { + Ok(()) + } + #[cfg(not(windows))] + fn inner(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs_err::metadata(path)?; + let mut perms = metadata.permissions(); + let mode = perms.mode(); + let new_mode = (mode & !0o777) | 0o755; + + // Check if permissions are ok already + if mode == new_mode { + return Ok(()); + } + + perms.set_mode(new_mode); + fs_err::set_permissions(path, perms) + } + + inner(path) +} diff --git a/src/languages/rust/version.rs b/src/languages/rust/version.rs index 351e31245..04db99b6a 100644 --- a/src/languages/rust/version.rs +++ b/src/languages/rust/version.rs @@ -1,19 +1,54 @@ use std::fmt::Display; use std::ops::Deref; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::str::FromStr; -use serde::Deserialize; - use crate::hook::InstallInfo; use crate::languages::version::{Error, try_into_u64_slice}; -#[derive(Debug, Clone, Deserialize)] -pub(crate) struct RustVersion(semver::Version); +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum Channel { + Stable, + Beta, + Nightly, +} + +impl FromStr for Channel { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "stable" => Ok(Channel::Stable), + "beta" => Ok(Channel::Beta), + "nightly" => Ok(Channel::Nightly), + _ => Err(()), + } + } +} + +impl Display for Channel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let channel_str = match self { + Channel::Stable => "stable", + Channel::Beta => "beta", + Channel::Nightly => "nightly", + }; + write!(f, "{channel_str}") + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RustVersion { + version: semver::Version, + channel: Option, +} impl Default for RustVersion { fn default() -> Self { - RustVersion(semver::Version::new(0, 0, 0)) + Self { + version: semver::Version::new(0, 0, 0), + channel: None, + } } } @@ -21,22 +56,55 @@ impl Deref for RustVersion { type Target = semver::Version; fn deref(&self) -> &Self::Target { - &self.0 + &self.version } } -impl Display for RustVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) +impl RustVersion { + pub(crate) fn from_version(version: &semver::Version) -> Self { + Self { + version: version.clone(), + channel: None, + } + } + + pub(crate) fn from_channel(channel: Channel) -> Self { + Self { + version: semver::Version::new(0, 0, 0), + channel: Some(channel), + } } -} -impl FromStr for RustVersion { - type Err = semver::Error; + pub(crate) fn from_path(version: &semver::Version, path: &Path) -> Self { + let toolchain_str = path + .file_name() + .and_then(|os_str| os_str.to_str()) + .unwrap_or_default(); + let path = toolchain_str.to_lowercase(); + let channel = if path.starts_with("nightly") { + Some(Channel::Nightly) + } else if path.starts_with("beta") { + Some(Channel::Beta) + } else if path.starts_with("stable") { + Some(Channel::Stable) + } else { + None + }; + Self { + version: version.clone(), + channel, + } + } - fn from_str(s: &str) -> Result { - let s = s.trim(); - semver::Version::parse(s).map(RustVersion) + pub(crate) fn to_toolchain_name(&self) -> String { + if let Some(channel) = &self.channel { + channel.to_string() + } else { + format!( + "{}.{}.{}", + self.version.major, self.version.minor, self.version.patch + ) + } } } @@ -48,15 +116,13 @@ impl FromStr for RustVersion { /// `beta` /// `1.70` or `1.70.0` /// `>= 1.70, < 1.72` -/// `local/path/to/rust` #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) enum RustRequest { Any, - Channel(String), + Channel(Channel), Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), - Path(PathBuf), Range(semver::VersionReq, String), } @@ -69,28 +135,36 @@ impl FromStr for RustRequest { } // Check for channel names - if s == "stable" || s == "nightly" || s == "beta" { - return Ok(RustRequest::Channel(s.to_string())); + if let Ok(channel) = Channel::from_str(s) { + return Ok(RustRequest::Channel(channel)); } // Try parsing as version numbers - Self::parse_version_numbers(s, s) - .or_else(|_| { - semver::VersionReq::parse(s) - .map(|version_req| RustRequest::Range(version_req, s.into())) - .map_err(|_| Error::InvalidVersion(s.to_string())) - }) - .or_else(|_| { - let path = PathBuf::from(s); - if path.exists() { - Ok(RustRequest::Path(path)) - } else { - Err(Error::InvalidVersion(s.to_string())) - } - }) + Self::parse_version_numbers(s, s).or_else(|_| { + semver::VersionReq::parse(s) + .map(|version_req| RustRequest::Range(version_req, s.into())) + .map_err(|_| Error::InvalidVersion(s.to_string())) + }) } } +impl Display for RustRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RustRequest::Any => write!(f, "any"), + RustRequest::Channel(channel) => write!(f, "{channel}"), + RustRequest::Major(major) => write!(f, "{major}"), + RustRequest::MajorMinor(major, minor) => write!(f, "{major}.{minor}"), + RustRequest::MajorMinorPatch(major, minor, patch) => { + write!(f, "{major}.{minor}.{patch}") + } + RustRequest::Range(_, range_str) => write!(f, "{range_str}"), + } + } +} + +pub(crate) const EXTRA_KEY_CHANNEL: &str = "channel"; + impl RustRequest { pub(crate) fn is_any(&self) -> bool { matches!(self, RustRequest::Any) @@ -116,41 +190,43 @@ impl RustRequest { RustRequest::Any => { // Any request accepts any valid installation, or specifically "stable" install_info - .get_extra("rust_channel") + .get_extra(EXTRA_KEY_CHANNEL) .is_some_and(|ch| ch == "stable") || install_info.language_version.major > 0 } - RustRequest::Channel(requested_channel) => install_info - .get_extra("rust_channel") - .is_some_and(|ch| ch == requested_channel), + RustRequest::Channel(requested_channel) => { + let channel = install_info + .get_extra(EXTRA_KEY_CHANNEL) + .and_then(|ch| Channel::from_str(ch).ok()); + channel.as_ref().is_some_and(|ch| ch == requested_channel) + } _ => { let version = &install_info.language_version; self.matches( - &RustVersion(version.clone()), + &RustVersion::from_version(version), Some(install_info.toolchain.as_ref()), ) } } } - pub(crate) fn matches(&self, version: &RustVersion, toolchain: Option<&Path>) -> bool { + pub(crate) fn matches(&self, version: &RustVersion, _toolchain: Option<&Path>) -> bool { match self { RustRequest::Any => true, - RustRequest::Channel(_requested_channel) => { - // Cannot match channel names against specific version numbers - // e.g. request "stable" against version "1.70.0" - // TODO: Resolve channel by querying rustup or Rust release API - false - } - RustRequest::Major(major) => version.0.major == *major, + RustRequest::Channel(requested_channel) => version + .channel + .as_ref() + .is_some_and(|ch| ch == requested_channel), + RustRequest::Major(major) => version.version.major == *major, RustRequest::MajorMinor(major, minor) => { - version.0.major == *major && version.0.minor == *minor + version.version.major == *major && version.version.minor == *minor } RustRequest::MajorMinorPatch(major, minor, patch) => { - version.0.major == *major && version.0.minor == *minor && version.0.patch == *patch + version.version.major == *major + && version.version.minor == *minor + && version.version.patch == *patch } - RustRequest::Path(path) => toolchain.is_some_and(|t| t == path), - RustRequest::Range(req, _) => req.matches(&version.0), + RustRequest::Range(req, _) => req.matches(&version.version), } } } @@ -161,6 +237,7 @@ mod tests { use crate::config::Language; use crate::hook::InstallInfo; use rustc_hash::FxHashSet; + use std::path::PathBuf; use std::str::FromStr; #[test] @@ -168,15 +245,15 @@ mod tests { assert_eq!(RustRequest::from_str("")?, RustRequest::Any); assert_eq!( RustRequest::from_str("stable")?, - RustRequest::Channel("stable".into()) + RustRequest::Channel(Channel::Stable) ); assert_eq!( RustRequest::from_str("beta")?, - RustRequest::Channel("beta".into()) + RustRequest::Channel(Channel::Beta) ); assert_eq!( RustRequest::from_str("nightly")?, - RustRequest::Channel("nightly".into()) + RustRequest::Channel(Channel::Nightly) ); assert_eq!(RustRequest::from_str("1")?, RustRequest::Major(1)); assert_eq!( @@ -194,12 +271,6 @@ mod tests { RustRequest::Range(semver::VersionReq::parse(range_str)?, range_str.into()) ); - let temp_dir = tempfile::tempdir()?; - let toolchain_path = temp_dir.path().join("rust-toolchain"); - std::fs::write(&toolchain_path, b"")?; - let path_request = RustRequest::from_str(toolchain_path.to_str().unwrap())?; - assert_eq!(path_request, RustRequest::Path(toolchain_path.clone())); - Ok(()) } @@ -213,11 +284,15 @@ mod tests { #[test] fn test_request_matches() -> anyhow::Result<()> { - let version = RustVersion::from_str("1.71.0")?; - let other_version = RustVersion::from_str("1.72.1")?; + let version = RustVersion::from_path( + &semver::Version::new(1, 71, 0), + Path::new("/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu"), + ); + let other_version = RustVersion::from_version(&semver::Version::new(1, 72, 1)); assert!(RustRequest::Any.matches(&version, None)); - assert!(!RustRequest::Channel("stable".into()).matches(&version, None)); + assert!(RustRequest::Channel(Channel::Stable).matches(&version, None)); + assert!(!RustRequest::Channel(Channel::Stable).matches(&other_version, None)); assert!(RustRequest::Major(1).matches(&version, None)); assert!(!RustRequest::Major(2).matches(&version, None)); assert!(RustRequest::MajorMinor(1, 71).matches(&version, None)); @@ -225,13 +300,6 @@ mod tests { assert!(RustRequest::MajorMinorPatch(1, 71, 0).matches(&version, None)); assert!(!RustRequest::MajorMinorPatch(1, 71, 1).matches(&version, None)); - let temp_dir = tempfile::tempdir()?; - let toolchain_path = temp_dir.path().join("rust-toolchain"); - std::fs::write(&toolchain_path, b"")?; - - assert!(RustRequest::Path(toolchain_path.clone()).matches(&version, Some(&toolchain_path))); - assert!(!RustRequest::Path(toolchain_path.clone()).matches(&version, None)); - let req = semver::VersionReq::parse(">=1.70, <1.72")?; assert!(RustRequest::Range(req.clone(), ">=1.70, <1.72".into()).matches(&version, None)); assert!(!RustRequest::Range(req, ">=1.70, <1.72".into()).matches(&other_version, None)); @@ -256,7 +324,6 @@ mod tests { assert!(RustRequest::MajorMinor(1, 71).satisfied_by(&install_info)); assert!(RustRequest::MajorMinorPatch(1, 71, 0).satisfied_by(&install_info)); assert!(!RustRequest::MajorMinorPatch(1, 71, 1).satisfied_by(&install_info)); - assert!(RustRequest::Path(toolchain_path.clone()).satisfied_by(&install_info)); let req = RustRequest::Range( semver::VersionReq::parse(">=1.70, <1.72")?, @@ -278,12 +345,12 @@ mod tests { install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) - .with_extra("rust_channel", "stable"); + .with_extra(EXTRA_KEY_CHANNEL, "stable"); // Channel request should match when extra is set - assert!(RustRequest::Channel("stable".into()).satisfied_by(&install_info)); - assert!(!RustRequest::Channel("nightly".into()).satisfied_by(&install_info)); - assert!(!RustRequest::Channel("beta".into()).satisfied_by(&install_info)); + assert!(RustRequest::Channel(Channel::Stable).satisfied_by(&install_info)); + assert!(!RustRequest::Channel(Channel::Nightly).satisfied_by(&install_info)); + assert!(!RustRequest::Channel(Channel::Beta).satisfied_by(&install_info)); Ok(()) } diff --git a/src/store.rs b/src/store.rs index 9ba56352a..9327ea766 100644 --- a/src/store.rs +++ b/src/store.rs @@ -206,7 +206,7 @@ pub(crate) enum ToolBucket { Node, Go, Ruby, - Rust, + Rustup, } impl ToolBucket { @@ -216,7 +216,7 @@ impl ToolBucket { ToolBucket::Node => "node", ToolBucket::Python => "python", ToolBucket::Ruby => "ruby", - ToolBucket::Rust => "rust", + ToolBucket::Rustup => "rustup", ToolBucket::Uv => "uv", } } @@ -227,7 +227,7 @@ pub(crate) enum CacheBucket { Uv, Go, Python, - Rust, + Cargo, Prek, } @@ -237,7 +237,7 @@ impl CacheBucket { CacheBucket::Go => "go", CacheBucket::Prek => "prek", CacheBucket::Python => "python", - CacheBucket::Rust => "rust", + CacheBucket::Cargo => "cargo", CacheBucket::Uv => "uv", } } diff --git a/tests/languages/rust.rs b/tests/languages/rust.rs index d5184f163..e602a9d2a 100644 --- a/tests/languages/rust.rs +++ b/tests/languages/rust.rs @@ -104,6 +104,39 @@ fn language_version() -> Result<()> { Ok(()) } +/// Test `rustup` installer. +fn rustup_installer() { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: rustup-test + name: rustup-test + language: rust + entry: rustc --version + "}); + context.git_add("."); + let filters = [(r"rustc 1\.\d{1,3}\.\d{1,2} .+", "rustc 1.X.X")] + .into_iter() + .chain(context.filters()) + .collect::>(); + + cmd_snapshot!(filters, context.run().arg("-v").env(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME, "non-exist-rustup"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + rustup-test..............................................................Passed + - hook id: rustup-test + - duration: [TIME] + + rustc 1.X.X + + ----- stderr ----- + "#); +} + /// Test that `additional_dependencies` with cli: prefix are installed correctly. #[test] fn additional_dependencies_cli() { diff --git a/tests/run.rs b/tests/run.rs index 104f6e1af..6d0588d31 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -2505,12 +2505,6 @@ fn system_language_version() { language_version: system entry: go version pass_filenames: false - - id: system-rust - name: system-rust - language: rust - language_version: system - entry: rustc --version - pass_filenames: false "}); context.git_add("."); @@ -2520,8 +2514,7 @@ fn system_language_version() { context.run() .arg("system-node") .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") - .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") - .env(EnvVars::PREK_INTERNAL__RUST_BINARY_NAME, "rust-never-exist"), @r" + .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist"), @r" success: false exit_code: 2 ----- stdout ----- @@ -2537,8 +2530,7 @@ fn system_language_version() { context.run() .arg("system-go") .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") - .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") - .env(EnvVars::PREK_INTERNAL__RUST_BINARY_NAME, "rust-never-exist"), @r" + .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist"), @r" success: false exit_code: 2 ----- stdout ----- @@ -2549,23 +2541,6 @@ fn system_language_version() { caused by: No suitable system Go version found and downloads are disabled "); - cmd_snapshot!( - context.filters(), - context.run() - .arg("system-rust") - .env(EnvVars::PREK_INTERNAL__GO_BINARY_NAME, "go-never-exist") - .env(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME, "node-never-exist") - .env(EnvVars::PREK_INTERNAL__RUST_BINARY_NAME, "rust-never-exist"), @r" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Failed to install hook `system-rust` - caused by: Failed to install rust - caused by: No suitable system Rust version found and downloads are disabled - "); - // When binaries are available, hooks pass. cmd_snapshot!(context.filters(), context.run(), @r" success: true @@ -2573,7 +2548,6 @@ fn system_language_version() { ----- stdout ----- system-node..............................................................Passed system-go................................................................Passed - system-rust..............................................................Passed ----- stderr ----- "); From 56f430afb53e1da14802e8b93dc221168171d387 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:02:54 +0800 Subject: [PATCH 2/4] Fix tests --- tests/languages/rust.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/languages/rust.rs b/tests/languages/rust.rs index e602a9d2a..6788beb68 100644 --- a/tests/languages/rust.rs +++ b/tests/languages/rust.rs @@ -43,7 +43,7 @@ fn language_version() -> Result<()> { "}); context.git_add("."); - let rust_dir = context.home_dir().child("tools").child("rust"); + let rust_dir = context.home_dir().child("tools/rustup/toolchains"); rust_dir.assert(predicates::path::missing()); let filters = [ From 719db7555bf2bba08b5342a8be4dc79b7a6c3d34 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:15:59 +0800 Subject: [PATCH 3/4] Avoid using bare cargo --- src/languages/rust/rust.rs | 16 +++++++++++----- tests/languages/rust.rs | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/languages/rust/rust.rs b/src/languages/rust/rust.rs index 004506470..f94349b9d 100644 --- a/src/languages/rust/rust.rs +++ b/src/languages/rust/rust.rs @@ -1,3 +1,4 @@ +use std::env::consts::EXE_EXTENSION; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -158,6 +159,11 @@ impl LanguageImpl for Rust { .install(version, allows_download) .await .context("Failed to install rust")?; + let cargo = rust + .toolchain() + .join("bin") + .join("cargo") + .with_extension(EXE_EXTENSION); let mut info = InstallInfo::new( hook.language, @@ -205,7 +211,7 @@ impl LanguageImpl for Rust { if lib_deps.is_empty() && !is_workspace { // For single packages without lib deps, use cargo install directly - Cmd::new("cargo", "install local") + Cmd::new(&cargo, "install local") .args(["install", "--bins", "--root"]) .arg(&info.env_path) .args(["--path", "."]) @@ -220,7 +226,7 @@ impl LanguageImpl for Rust { // For workspace members without lib deps, use cargo build + copy // (cargo install doesn't work well with virtual workspaces) let target_dir = info.env_path.join("target"); - Cmd::new("cargo", "build local") + Cmd::new(&cargo, "build local") .args(["build", "--bins", "--release"]) .arg("--manifest-path") .arg(package_dir.join("Cargo.toml")) @@ -274,7 +280,7 @@ impl LanguageImpl for Rust { } // Run cargo add on the copied manifest - let mut cmd = Cmd::new("cargo", "add dependencies"); + let mut cmd = Cmd::new(&cargo, "add dependencies"); cmd.arg("add"); for dep in &lib_deps { cmd.arg(format_cargo_dependency(dep.as_str())); @@ -290,7 +296,7 @@ impl LanguageImpl for Rust { // Build using cargo build with --manifest-path pointing to modified manifest // but source files come from original package_dir let target_dir = info.env_path.join("target"); - let mut cmd = Cmd::new("cargo", "build local with deps"); + let mut cmd = Cmd::new(&cargo, "build local with deps"); cmd.args(["build", "--bins", "--release"]) .arg("--manifest-path") .arg(&dst_manifest) @@ -318,7 +324,7 @@ impl LanguageImpl for Rust { // Install CLI dependencies for cli_dep in cli_deps { let (package, version) = cli_dep.split_once(':').unwrap_or((cli_dep, "")); - let mut cmd = Cmd::new("cargo", "install cli dep"); + let mut cmd = Cmd::new(&cargo, "install cli dep"); cmd.args(["install", "--bins", "--root"]) .arg(&info.env_path) .arg(package); diff --git a/tests/languages/rust.rs b/tests/languages/rust.rs index 6788beb68..10347ed5e 100644 --- a/tests/languages/rust.rs +++ b/tests/languages/rust.rs @@ -105,6 +105,7 @@ fn language_version() -> Result<()> { } /// Test `rustup` installer. +#[test] fn rustup_installer() { let context = TestContext::new(); context.init_project(); From e950caf698e4d1f44474b8a5c9e132ffdbc24fee Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:28:23 +0800 Subject: [PATCH 4/4] Update crates/prek-consts/src/env_vars.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/prek-consts/src/env_vars.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index b23a6e8b7..04c392672 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -34,7 +34,7 @@ impl EnvVars { "PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT"; pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = "PREK_INTERNAL__GO_BINARY_NAME"; pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = "PREK_INTERNAL__NODE_BINARY_NAME"; - pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = "PREK_INTERNAL__RUST_BINARY_NAME"; + pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = "PREK_INTERNAL__RUSTUP_BINARY_NAME"; pub const PREK_GENERATE: &'static str = "PREK_GENERATE"; // Python & uv related