From 1893d5d4a57e69e91f1ff7ceec64f85a8b8ac0e2 Mon Sep 17 00:00:00 2001 From: Kaur Kuut Date: Sun, 8 Feb 2026 00:57:08 +0200 Subject: [PATCH] Add automatic tool management starting with `rustup` and `cargo`. --- CHANGELOG.md | 2 + prep/src/cmd/clippy.rs | 5 +-- prep/src/cmd/format.rs | 5 +-- prep/src/main.rs | 1 + prep/src/session.rs | 3 ++ prep/src/tools/cargo.rs | 92 ++++++++++++++++++++++++++++++++++++++++ prep/src/tools/mod.rs | 5 +++ prep/src/tools/rustup.rs | 77 +++++++++++++++++++++++++++++++++ 8 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 prep/src/tools/cargo.rs create mode 100644 prep/src/tools/mod.rs create mode 100644 prep/src/tools/rustup.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36586ca..33b4faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * `init` command to set up custom per-project Prep configuration. ([#23] by [@xStrom]) * `copyright` command to easily verify that all Rust source files have correct copyright headers. ([#22] by [@xStrom]) * `--crates ` option to the `clippy` command. ([#18] by [@xStrom]) +* Automatic Cargo installation via `rustup`. ([#24] by [@xStrom]) * Ability to run from within a sub-directory of a workspace as opposed to just from the workspace root. ([#23] by [@xStrom]) ### Changed @@ -29,6 +30,7 @@ [#18]: https://github.com/Nevermore/prep/pull/18 [#22]: https://github.com/Nevermore/prep/pull/22 [#23]: https://github.com/Nevermore/prep/pull/23 +[#24]: https://github.com/Nevermore/prep/pull/24 [Unreleased]: https://github.com/Nevermore/prep/compare/v0.1.0...HEAD [0.1.0]: https://github.com/Nevermore/prep/compare/v0.0.0...v0.1.0 diff --git a/prep/src/cmd/clippy.rs b/prep/src/cmd/clippy.rs index 1c319a6..786b97c 100644 --- a/prep/src/cmd/clippy.rs +++ b/prep/src/cmd/clippy.rs @@ -1,19 +1,18 @@ // Copyright 2026 the Prep Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::process::Command; - use anyhow::{Context, ensure}; use crate::cmd::CargoTargets; use crate::session::Session; +use crate::tools::cargo; use crate::ui; /// Runs Clippy analysis on the given `targets`. /// /// In `strict` mode warnings are treated as errors. pub fn run(session: &Session, targets: CargoTargets, strict: bool) -> anyhow::Result<()> { - let mut cmd = Command::new("cargo"); + let mut cmd = cargo::new("")?; let mut cmd = cmd .current_dir(session.root_dir()) .arg("clippy") diff --git a/prep/src/cmd/format.rs b/prep/src/cmd/format.rs index b6b9136..7018e93 100644 --- a/prep/src/cmd/format.rs +++ b/prep/src/cmd/format.rs @@ -1,16 +1,15 @@ // Copyright 2026 the Prep Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::process::Command; - use anyhow::{Context, ensure}; use crate::session::Session; +use crate::tools::cargo; use crate::ui; /// Format the workspace pub fn run(session: &Session, check: bool) -> anyhow::Result<()> { - let mut cmd = Command::new("cargo"); + let mut cmd = cargo::new("")?; let mut cmd = cmd.current_dir(session.root_dir()).arg("fmt").arg("--all"); if check { cmd = cmd.arg("--check"); diff --git a/prep/src/main.rs b/prep/src/main.rs index a761dc0..ab656d1 100644 --- a/prep/src/main.rs +++ b/prep/src/main.rs @@ -6,6 +6,7 @@ mod cmd; mod config; mod session; +mod tools; mod ui; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; diff --git a/prep/src/session.rs b/prep/src/session.rs index b5642a5..b8a6f7f 100644 --- a/prep/src/session.rs +++ b/prep/src/session.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result, bail}; use cargo_metadata::MetadataCommand; use crate::config::Config; +use crate::tools::cargo; const PREP_DIR: &str = ".prep"; const CONFIG_FILE: &str = "prep.toml"; @@ -44,7 +45,9 @@ impl Session { let root_dir = match root_dir { Some(root_dir) => root_dir, None => { + let cmd = cargo::new("")?; let metadata = MetadataCommand::new() + .cargo_path(cmd.get_program()) .exec() .context("failed to fetch Cargo metadata")?; let workspace_dir = metadata.workspace_root.into_std_path_buf(); diff --git a/prep/src/tools/cargo.rs b/prep/src/tools/cargo.rs new file mode 100644 index 0000000..961f9fa --- /dev/null +++ b/prep/src/tools/cargo.rs @@ -0,0 +1,92 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::collections::HashMap; +use std::io::ErrorKind; +use std::process::Command; +use std::sync::{LazyLock, RwLock}; + +use anyhow::{Context, Result, bail, ensure}; + +use crate::tools::rustup; +use crate::ui; + +/// Cargo executable name. +const BIN: &str = "cargo"; + +/// Toolchain version -> Cargo path +static PATHS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Returns the cargo command. +/// +/// Provide an empty `version` to pick the default cargo version. +pub fn new(version: &str) -> Result { + let paths = PATHS.read().expect("cargo setup lock poisoned"); + if let Some(path) = paths.get(version) { + return Ok(Command::new(path)); + } + drop(paths); + let mut paths = PATHS.write().expect("cargo setup lock poisoned"); + if let Some(path) = paths.get(version) { + return Ok(Command::new(path)); + } + let path = set_up(version)?; + let cmd = Command::new(&path); + paths.insert(version.into(), path); + Ok(cmd) +} + +/// Ensures that Cargo is installed and ready to use. +pub fn set_up(version: &str) -> Result { + // TODO: Call rustup toolchain install with correct components etc first + + let mut cmd = rustup::new()?; + let mut cmd = cmd.arg("which").arg(BIN); + if !version.is_empty() { + cmd = cmd.args(["--toolchain", version]); + } + + ui::print_cmd(cmd); + + let output = cmd.output().context("failed to run rustup")?; + ensure!(output.status.success(), "rustup failed: {}", output.status); + + let path = String::from_utf8(output.stdout).context("rustup output not valid UTF-8")?; + let path = path.trim(); + + if !verify(path, version)? { + bail!("cargo not found"); + } + + Ok(path.into()) +} + +/// Returns `true` if Cargo was found, `false` if no Cargo was found. +/// +/// Other versions will return an error. +pub fn verify(path: &str, version: &str) -> Result { + let mut cmd = Command::new(path); + let cmd = cmd.arg("-V"); + + ui::print_cmd(cmd); + + let output = cmd.output(); + if output + .as_ref() + .is_err_and(|e| e.kind() == ErrorKind::NotFound) + { + return Ok(false); + } + let output = output.context("failed to run cargo")?; + ensure!(output.status.success(), "cargo failed: {}", output.status); + + let cmd_version = String::from_utf8(output.stdout).context("cargo output not valid UTF-8")?; + + let expected = format!("cargo {version}"); + if !cmd_version.starts_with(&expected) { + bail!("expected {expected}, got: {cmd_version}"); + } + + Ok(true) +} diff --git a/prep/src/tools/mod.rs b/prep/src/tools/mod.rs new file mode 100644 index 0000000..a98f88e --- /dev/null +++ b/prep/src/tools/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +pub mod cargo; +pub mod rustup; diff --git a/prep/src/tools/rustup.rs b/prep/src/tools/rustup.rs new file mode 100644 index 0000000..5033447 --- /dev/null +++ b/prep/src/tools/rustup.rs @@ -0,0 +1,77 @@ +// Copyright 2026 the Prep Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use std::io::ErrorKind; +use std::process::Command; +use std::sync::RwLock; + +use anyhow::{Context, Result, bail, ensure}; + +use crate::ui; + +/// Rustup executable name. +const BIN: &str = "rustup"; + +/// Whether rustup is ready to use. +static READY: RwLock = RwLock::new(false); + +/// Returns the rustup command. +pub fn new() -> Result { + if !*READY.read().expect("rustup setup lock poisoned") { + let mut ready = READY.write().expect("rustup setup lock poisoned"); + if !*ready { + set_up().context("failed to set up rustup")?; + *ready = true; + } + } + Ok(Command::new(BIN)) +} + +/// Ensures that rustup is installed and ready to use. +pub fn set_up() -> Result<()> { + // Check if rustup is already available + let found = verify()?; + if !found { + ui::print_err( + "\ + Prep requires rustup v1 to function.\n\ + \n\ + There is no automatic setup implemented for it, sorry.\n\ + Please go to https://rustup.rs/ and install it manually.\n\ + \n\ + If you already have rustup installed then this error here is probably a bug.\n\ + Please report it at https://github.com/Nevermore/prep\n\ + ", + ); + bail!("rustup not found"); + } + Ok(()) +} + +/// Returns `true` if rustup v1 was found, `false` if no rustup was found. +/// +/// Other versions will return an error. +pub fn verify() -> Result { + let mut cmd = Command::new(BIN); + let cmd = cmd.arg("-V"); + + ui::print_cmd(cmd); + + let output = cmd.output(); + if output + .as_ref() + .is_err_and(|e| e.kind() == ErrorKind::NotFound) + { + return Ok(false); + } + let output = output.context("failed to run rustup")?; + ensure!(output.status.success(), "rustup failed: {}", output.status); + + let version = String::from_utf8(output.stdout).context("rustup output not valid UTF-8")?; + + if !version.starts_with("rustup 1.") { + bail!("expected rustup v1, got: {version}"); + } + + Ok(true) +}