diff --git a/Project.toml b/Project.toml index 8f4aa36..d4de37a 100644 --- a/Project.toml +++ b/Project.toml @@ -4,16 +4,18 @@ version = "0.1.5" authors = ["Satoshi Terasaki "] [deps] +Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +Scratch = "6c6a2e73-6563-6170-7368-637461726353" [compat] Pkg = "1" +Scratch = "1" julia = "1.10" [extras] -Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Downloads", "TOML", "Test"] +test = ["TOML", "Test"] diff --git a/README.md b/README.md index b5ae59e..d9abb36 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,11 @@ run(`$(cargo()) build --release`) ## Internal Implementation -This package uses Julia's Artifacts system to automatically download and manage Rust toolchains. On first use, the appropriate Rust toolchain for your platform will be automatically downloaded. +This package uses Julia's Artifacts system to automatically download and manage Rust toolchains on Unix-like platforms. On first use, the appropriate Rust toolchain for your platform will be automatically downloaded. + +Windows uses a different installation path. Instead of installing the large Rust distribution tarball through Julia Artifacts, RustToolChain.jl downloads `rustup-init.exe` from `https://win.rustup.rs` and installs the Rust version recorded in `Artifacts.toml` with `rustup toolchain install --profile complete`. The installation is isolated under this package's Julia scratchspace by setting package-local `RUSTUP_HOME` and `CARGO_HOME` directories. It does not modify the user's `PATH` or the user's existing Rust installation. + +The `cargo()` and `rustc()` functions return commands that use the isolated Windows toolchain when no system `cargo` or `rustc` is available. ## Development diff --git a/src/RustToolChain.jl b/src/RustToolChain.jl index 86df3b3..6fc63cc 100644 --- a/src/RustToolChain.jl +++ b/src/RustToolChain.jl @@ -2,10 +2,137 @@ module RustToolChain const ARTIFACTS_TOML = joinpath(pkgdir(@__MODULE__), "Artifacts.toml") const EXE_EXT = Sys.iswindows() ? ".exe" : "" +const REQUIRED_TOOLS = ("cargo", "rustc") +const WINDOWS_REQUIRED_TOOLS = ("cargo", "rustc", "rustfmt", "clippy-driver") +const DIST_METADATA_FILES = Set(( + "components", + "install.sh", + "manifest.in", + "rust-installer-version", + "uninstall.sh", +)) export cargo, rustc -using Pkg.Artifacts: ensure_artifact_installed, artifact_path, artifact_hash +using Downloads: download +using Pkg.Artifacts: artifact_hash, artifact_meta, artifact_path, ensure_artifact_installed +using Scratch: @get_scratch! + +function _toolchain_prefix_ready(prefix::AbstractString) + return all(tool -> isfile(joinpath(prefix, "bin", tool * EXE_EXT)), REQUIRED_TOOLS) +end + +function _copy_tree!(src::AbstractString, dst::AbstractString) + if isdir(src) + mkpath(dst) + for name in readdir(src) + _copy_tree!(joinpath(src, name), joinpath(dst, name)) + end + else + mkpath(dirname(dst)) + cp(src, dst; force=true) + end + return nothing +end + +function _dist_components(rust_dir::AbstractString) + components_file = joinpath(rust_dir, "components") + if isfile(components_file) + return filter(!isempty, strip.(readlines(components_file))) + end + + return filter(name -> isdir(joinpath(rust_dir, name)), readdir(rust_dir)) +end + +function _install_from_unpacked_dist!(rust_dir::AbstractString, prefix::AbstractString) + mkpath(prefix) + + for component in _dist_components(rust_dir) + component_dir = joinpath(rust_dir, component) + isdir(component_dir) || continue + + for name in readdir(component_dir) + name in DIST_METADATA_FILES && continue + _copy_tree!(joinpath(component_dir, name), joinpath(prefix, name)) + end + end + + _toolchain_prefix_ready(prefix) || error( + "Rust toolchain artifact was installed to $prefix, but cargo/rustc were not found", + ) + + return prefix +end + +function _rust_version_from_artifact_metadata() + meta = artifact_meta("RustToolChain", ARTIFACTS_TOML) + downloads = get(meta, "download", []) + isempty(downloads) && error("RustToolChain artifact metadata does not contain a download URL") + + url = get(first(downloads), "url", "") + matched = match(r"rust-(\d+\.\d+\.\d+)-", url) + matched === nothing && error("Could not determine Rust version from artifact URL: $url") + return matched.captures[1] +end + +function _windows_rustup_init_url() + arch = Sys.ARCH === :aarch64 ? "aarch64" : "x86_64" + return "https://win.rustup.rs/$arch" +end + +function _windows_host_triple() + arch = Sys.ARCH === :aarch64 ? "aarch64" : "x86_64" + return "$arch-pc-windows-msvc" +end + +function _windows_rustup_env(toolchain::AbstractString) + scratch = @get_scratch!("rustup-windows") + rustup_home = joinpath(scratch, "rustup") + cargo_home = joinpath(scratch, "cargo") + + env = copy(ENV) + env["RUSTUP_HOME"] = rustup_home + env["CARGO_HOME"] = cargo_home + env["RUSTUP_TOOLCHAIN"] = toolchain + + return scratch, rustup_home, cargo_home, env +end + +function _windows_rustup_toolchain_ready( + rustup_home::AbstractString, + cargo_home::AbstractString, + toolchain::AbstractString, +) + toolchain_dir = joinpath(rustup_home, "toolchains", "$toolchain-$(_windows_host_triple())") + return isdir(toolchain_dir) && + all(tool -> isfile(joinpath(cargo_home, "bin", tool * ".exe")), WINDOWS_REQUIRED_TOOLS) +end + +function _ensure_windows_rust_toolchain_installed() + toolchain = _rust_version_from_artifact_metadata() + scratch, rustup_home, cargo_home, env = _windows_rustup_env(toolchain) + rustup_path = joinpath(cargo_home, "bin", "rustup.exe") + + if !isfile(rustup_path) + mkpath(scratch) + rustup_init = joinpath(scratch, "rustup-init.exe") + @info "Downloading rustup-init for isolated Windows Rust toolchain" url = _windows_rustup_init_url() + download(_windows_rustup_init_url(), rustup_init) + run(setenv(`$rustup_init --default-toolchain none --no-modify-path -y`, env)) + end + + if !_windows_rustup_toolchain_ready(rustup_home, cargo_home, toolchain) + @info "Installing isolated Windows Rust toolchain" toolchain profile = "complete" + run(setenv(`$rustup_path toolchain install $toolchain --profile complete --no-self-update`, env)) + run(setenv(`$rustup_path default $toolchain`, env)) + end + + _windows_rustup_toolchain_ready(rustup_home, cargo_home, toolchain) || error( + "Windows Rust toolchain installation completed, but cargo/rustc were not found in $cargo_home", + ) + + return cargo_home, env +end """ ensure_rust_toolchain_installed() @@ -15,6 +142,11 @@ If not already installed, downloads and installs the Rust toolchain artifact. Returns the installation prefix directory containing the Rust binaries. """ function ensure_rust_toolchain_installed() + if Sys.iswindows() + cargo_home, _ = _ensure_windows_rust_toolchain_installed() + return cargo_home + end + ensure_artifact_installed("RustToolChain", ARTIFACTS_TOML) toolchain_dir = artifact_path(artifact_hash("RustToolChain", ARTIFACTS_TOML)) # The Rust toolchain is unpacked inside a rust-*-*/ directory @@ -23,10 +155,15 @@ function ensure_rust_toolchain_installed() rust_dir = first(rust_dirs) prefix = joinpath(toolchain_dir, rust_dir, "prefix") - if isdir(prefix) + if _toolchain_prefix_ready(prefix) return prefix + elseif Sys.iswindows() + return _install_from_unpacked_dist!(joinpath(toolchain_dir, rust_dir), prefix) else run(`bash $(joinpath(toolchain_dir, rust_dir, "install.sh")) --prefix=$(prefix) --disable-ldconfig`) + _toolchain_prefix_ready(prefix) || error( + "Rust toolchain installer completed, but cargo/rustc were not found in $prefix", + ) return prefix end end @@ -38,6 +175,12 @@ Get the cargo executable command from Julia's Artifacts system. Downloads and installs the Rust toolchain if not already present. """ function cargo_cmd_from_artifacts() + if Sys.iswindows() + cargo_home, env = _ensure_windows_rust_toolchain_installed() + cargo_path = joinpath(cargo_home, "bin", "cargo.exe") + return setenv(`$cargo_path`, env) + end + prefix = ensure_rust_toolchain_installed() cargo_path = joinpath(prefix, "bin", "cargo" * EXE_EXT) @assert isfile(cargo_path) "Cargo executable not found at $cargo_path" @@ -54,7 +197,13 @@ Get the rustc executable command from Julia's Artifacts system. Downloads and installs the Rust toolchain if not already present. """ function rustc_cmd_from_artifacts() - prefix =ensure_rust_toolchain_installed() + if Sys.iswindows() + cargo_home, env = _ensure_windows_rust_toolchain_installed() + rustc_path = joinpath(cargo_home, "bin", "rustc.exe") + return setenv(`$rustc_path`, env) + end + + prefix = ensure_rust_toolchain_installed() rustc_path = joinpath(prefix, "bin", "rustc" * EXE_EXT) return `$rustc_path --sysroot $(prefix)` end diff --git a/test/runtests.jl b/test/runtests.jl index 81440f2..6699947 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,6 +9,26 @@ include("generator_platforms.jl") @test rustc() isa Cmd end +@testset "unpacked dist installer" begin + mktempdir() do dir + rust_dir = joinpath(dir, "rust-test") + cargo_exe = "cargo" * RustToolChain.EXE_EXT + rustc_exe = "rustc" * RustToolChain.EXE_EXT + + mkpath(joinpath(rust_dir, "cargo", "bin")) + mkpath(joinpath(rust_dir, "rustc", "bin")) + write(joinpath(rust_dir, "components"), "cargo\nrustc\n") + write(joinpath(rust_dir, "cargo", "bin", cargo_exe), "") + write(joinpath(rust_dir, "rustc", "bin", rustc_exe), "") + + prefix = joinpath(rust_dir, "prefix") + RustToolChain._install_from_unpacked_dist!(rust_dir, prefix) + + @test isfile(joinpath(prefix, "bin", cargo_exe)) + @test isfile(joinpath(prefix, "bin", rustc_exe)) + end +end + @testset "cargo --version" begin @test success(`$(cargo()) --version`) end